chore(web): prettier 一次性格式化并在 CI 启用 format:check

对 47 个前端文件统一执行 npm run format,消除存量格式差异。
随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
This commit is contained in:
oudecheng 2026-08-03 23:25:22 +08:00
parent cda14360af
commit c724bbf864
49 changed files with 6404 additions and 4278 deletions

View File

@ -74,6 +74,10 @@ jobs:
working-directory: web working-directory: web
run: npm run lint run: npm run lint
- name: Format check (prettier)
working-directory: web
run: npm run format:check
- name: Type check - name: Type check
working-directory: web working-directory: web
run: npx tsc --noEmit run: npx tsc --noEmit

View File

@ -49,8 +49,9 @@ clean:
check: check:
@echo "Checking formatting..." @echo "Checking formatting..."
cargo fmt --all -- --check cargo fmt --all -- --check
@echo "Checking frontend (lint + build)..." @echo "Checking frontend (lint + format + build)..."
cd web && npm run lint cd web && npm run lint
cd web && npm run format:check
cd web && npm run build cd web && npm run build
@echo "Checking Rust code..." @echo "Checking Rust code..."
cargo check cargo check

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ export const API = {
expertsSelect: '/api/experts/select', expertsSelect: '/api/experts/select',
sessionSelectModel: '/api/session/select-model', sessionSelectModel: '/api/session/select-model',
sessionSelectedModel: '/api/session/selected-model', sessionSelectedModel: '/api/session/selected-model',
} as const } as const;
/** /**
* fetch JSON headers * fetch JSON headers
@ -28,7 +28,7 @@ export const API = {
*/ */
export async function apiFetch<T>( export async function apiFetch<T>(
endpoint: string, endpoint: string,
options?: { method?: string; body?: unknown; signal?: AbortSignal } options?: { method?: string; body?: unknown; signal?: AbortSignal },
): Promise<[T | null, { status: number; message: string } | null]> { ): Promise<[T | null, { status: number; message: string } | null]> {
try { try {
const resp = await fetch(endpoint, { const resp = await fetch(endpoint, {
@ -36,14 +36,17 @@ export async function apiFetch<T>(
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined, headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined, body: options?.body ? JSON.stringify(options.body) : undefined,
signal: options?.signal, signal: options?.signal,
}) });
const data = await resp.json().catch(() => null) const data = await resp.json().catch(() => null);
if (!resp.ok) { if (!resp.ok) {
return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }] return [
null,
{ status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` },
];
} }
return [data as T, null] return [data as T, null];
} catch (e) { } catch (e) {
return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }] return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }];
} }
} }
@ -52,10 +55,10 @@ export async function apiFetch<T>(
*/ */
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> { export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
try { try {
const resp = await fetch(endpoint) const resp = await fetch(endpoint);
if (!resp.ok) return null if (!resp.ok) return null;
return await resp.json() as T return (await resp.json()) as T;
} catch { } catch {
return null return null;
} }
} }

View File

@ -1,32 +1,35 @@
import { API, apiFetch } from './client' import { API, apiFetch } from './client';
import type { AppConfig } from '../components/Settings/types' import type { AppConfig } from '../components/Settings/types';
export interface RestartResponse { export interface RestartResponse {
success: boolean success: boolean;
message?: string message?: string;
} }
export async function getAppConfig(): Promise<[AppConfig | null, string | null]> { export async function getAppConfig(): Promise<[AppConfig | null, string | null]> {
const [data, err] = await apiFetch<AppConfig>(API.config) const [data, err] = await apiFetch<AppConfig>(API.config);
return [data, err?.message ?? null] return [data, err?.message ?? null];
} }
export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> { export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> {
const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } }) const [, err] = await apiFetch<{ success: boolean }>(API.config, {
return err ? [false, err.message] : [true, null] method: 'PUT',
body: { config },
});
return err ? [false, err.message] : [true, null];
} }
export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> { export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> {
const resp = await fetch(API.restart, { method: 'POST' }) const resp = await fetch(API.restart, { method: 'POST' });
const data = await resp.json().catch(() => ({ success: false })) const data = await resp.json().catch(() => ({ success: false }));
return { status: resp.status, data } return { status: resp.status, data };
} }
export async function checkHealth(): Promise<boolean> { export async function checkHealth(): Promise<boolean> {
try { try {
const resp = await fetch(API.health) const resp = await fetch(API.health);
return resp.ok return resp.ok;
} catch { } catch {
return false return false;
} }
} }

View File

@ -1,77 +1,113 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client';
import type { ExpertListResponse, ExpertItem, CapabilityPolicy, ModelOptionsResponse } from '../components/Settings/types' import type {
ExpertListResponse,
ExpertItem,
CapabilityPolicy,
ModelOptionsResponse,
} from '../components/Settings/types';
export function listExperts(): Promise<ExpertListResponse | null> { export function listExperts(): Promise<ExpertListResponse | null> {
return apiGetSilent<ExpertListResponse>(API.experts) return apiGetSilent<ExpertListResponse>(API.experts);
} }
export function listModelOptions(): Promise<ModelOptionsResponse | null> { export function listModelOptions(): Promise<ModelOptionsResponse | null> {
return apiGetSilent<ModelOptionsResponse>(API.modelOptions) return apiGetSilent<ModelOptionsResponse>(API.modelOptions);
} }
export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> { export async function toggleExpert(
name: string,
scope: string,
enabled: boolean,
): Promise<Response> {
return fetch(API.expertsToggle, { return fetch(API.expertsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }), body: JSON.stringify({ name, scope, enabled }),
}) });
} }
export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> { export async function createExpert(payload: {
name: string;
description: string;
body: string;
scope: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}): Promise<Response> {
return fetch(API.expertsCreate, { return fetch(API.expertsCreate, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
} }
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> { export async function updateExpert(payload: {
name: string;
scope: string;
description?: string;
body?: string;
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}): Promise<Response> {
return fetch(API.expertsUpdate, { return fetch(API.expertsUpdate, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
} }
export async function deleteExpert(name: string, scope: string): Promise<Response> { export async function deleteExpert(name: string, scope: string): Promise<Response> {
const params = new URLSearchParams({ name, scope }) const params = new URLSearchParams({ name, scope });
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' }) return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' });
} }
export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> { export async function getSelectedExpert(
const params = new URLSearchParams({ session_id: sessionId }) sessionId: string,
const resp = await fetch(`${API.expertsSelected}?${params}`) ): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
if (!resp.ok) return { expert_name: null, expert: null } const params = new URLSearchParams({ session_id: sessionId });
return resp.json() const resp = await fetch(`${API.expertsSelected}?${params}`);
if (!resp.ok) return { expert_name: null, expert: null };
return resp.json();
} }
export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> { export async function selectExpert(
sessionId: string,
expertName: string | null,
): Promise<{ success: boolean; error?: string }> {
const resp = await fetch(API.expertsSelect, { const resp = await fetch(API.expertsSelect, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }), body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
}) });
const data = await resp.json().catch(() => ({})) const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' } if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' };
return { success: true } return { success: true };
} }
/** 设置或清除session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */ /** 设置或清除session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */
export async function selectModel(sessionId: string, provider: string | null, model: string | null): Promise<{ success: boolean; error?: string }> { export async function selectModel(
sessionId: string,
provider: string | null,
model: string | null,
): Promise<{ success: boolean; error?: string }> {
const resp = await fetch(API.sessionSelectModel, { const resp = await fetch(API.sessionSelectModel, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, provider, model }), body: JSON.stringify({ session_id: sessionId, provider, model }),
}) });
const data = await resp.json().catch(() => ({})) const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' } if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' };
return { success: true } return { success: true };
} }
/** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */ /** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */
export async function getSelectedModel(sessionId: string): Promise<{ provider: string | null; model: string | null }> { export async function getSelectedModel(
const params = new URLSearchParams({ session_id: sessionId }) sessionId: string,
const resp = await fetch(`${API.sessionSelectedModel}?${params}`) ): Promise<{ provider: string | null; model: string | null }> {
if (!resp.ok) return { provider: null, model: null } const params = new URLSearchParams({ session_id: sessionId });
return resp.json() const resp = await fetch(`${API.sessionSelectedModel}?${params}`);
if (!resp.ok) return { provider: null, model: null };
return resp.json();
} }

View File

@ -1,6 +1,6 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client';
import type { McpStatusResponse } from '../components/Settings/types' import type { McpStatusResponse } from '../components/Settings/types';
export function getMcpStatus(): Promise<McpStatusResponse | null> { export function getMcpStatus(): Promise<McpStatusResponse | null> {
return apiGetSilent<McpStatusResponse>(API.mcpStatus) return apiGetSilent<McpStatusResponse>(API.mcpStatus);
} }

View File

@ -1,14 +1,18 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client';
import type { SkillListResponse } from '../components/Settings/types' import type { SkillListResponse } from '../components/Settings/types';
export function listSkills(): Promise<SkillListResponse | null> { export function listSkills(): Promise<SkillListResponse | null> {
return apiGetSilent<SkillListResponse>(API.skills) return apiGetSilent<SkillListResponse>(API.skills);
} }
export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise<Response> { export async function toggleSkill(
name: string,
scope: string,
enabled: boolean,
): Promise<Response> {
return fetch(API.skillsToggle, { return fetch(API.skillsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }), body: JSON.stringify({ name, scope, enabled }),
}) });
} }

View File

@ -1,29 +1,33 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client';
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types' import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types';
export function listSubagents(): Promise<SubagentListResponse | null> { export function listSubagents(): Promise<SubagentListResponse | null> {
return apiGetSilent<SubagentListResponse>(API.subagents) return apiGetSilent<SubagentListResponse>(API.subagents);
} }
export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise<Response> { export async function toggleSubagent(
name: string,
scope: string,
enabled: boolean,
): Promise<Response> {
return fetch(API.subagentsToggle, { return fetch(API.subagentsToggle, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, scope, enabled }), body: JSON.stringify({ name, scope, enabled }),
}) });
} }
export async function updateSubagent(payload: { export async function updateSubagent(payload: {
name: string name: string;
description?: string description?: string;
body?: string body?: string;
capability?: CapabilityPolicy capability?: CapabilityPolicy;
provider?: string provider?: string;
model?: string model?: string;
}): Promise<Response> { }): Promise<Response> {
return fetch(API.subagentsUpdate, { return fetch(API.subagentsUpdate, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) });
} }

View File

@ -1,6 +1,6 @@
import { API, apiGetSilent } from './client' import { API, apiGetSilent } from './client';
import type { ToolsListResponse } from '../components/Settings/types' import type { ToolsListResponse } from '../components/Settings/types';
export function listTools(): Promise<ToolsListResponse | null> { export function listTools(): Promise<ToolsListResponse | null> {
return apiGetSilent<ToolsListResponse>(API.tools) return apiGetSilent<ToolsListResponse>(API.tools);
} }

View File

@ -1,29 +1,29 @@
import { useState } from 'react' import { useState } from 'react';
import { MessageList } from './MessageList' import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput' import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector' import { ExpertSelector } from './ExpertSelector';
import { ModelSelector } from './ModelSelector' import { ModelSelector } from './ModelSelector';
import type { ChatMessage, Attachment } from '../../types/protocol' import type { ChatMessage, Attachment } from '../../types/protocol';
interface ChatContainerProps { interface ChatContainerProps {
messages: ChatMessage[] messages: ChatMessage[];
isLoading: boolean isLoading: boolean;
isReadOnly?: boolean isReadOnly?: boolean;
channelName?: string channelName?: string;
onSendMessage: (content: string, attachments: Attachment[]) => void onSendMessage: (content: string, attachments: Attachment[]) => void;
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
onStop?: () => void onStop?: () => void;
showThinking?: boolean showThinking?: boolean;
/** 视图标识,用于保存/恢复滚动位置 */ /** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string viewKey?: string;
/** 高亮的消息 ID */ /** 高亮的消息 ID */
highlightedMessageId?: string | null highlightedMessageId?: string | null;
/** 当前 session ID用于专家选择 */ /** 当前 session ID用于专家选择 */
sessionId?: string | null sessionId?: string | null;
/** 打开设置页(用于专家管理入口) */ /** 打开设置页(用于专家管理入口) */
onOpenSettings?: () => void onOpenSettings?: () => void;
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */ /** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
settingsClosedTick?: number settingsClosedTick?: number;
} }
export function ChatContainer({ export function ChatContainer({
@ -41,12 +41,21 @@ export function ChatContainer({
onOpenSettings, onOpenSettings,
settingsClosedTick, settingsClosedTick,
}: ChatContainerProps) { }: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null) const [selectedExpert, setSelectedExpert] = useState<{
name: string;
description: string;
} | null>(null);
return ( return (
<div className="flex h-full flex-col relative"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} /> <MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
/>
</div> </div>
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full"> <div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
<ExpertSelector <ExpertSelector
@ -55,10 +64,7 @@ export function ChatContainer({
onSelectionChange={setSelectedExpert} onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick} settingsClosedTick={settingsClosedTick}
/> />
<ModelSelector <ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} />
sessionId={sessionId ?? null}
settingsClosedTick={settingsClosedTick}
/>
</div> </div>
<MessageInput <MessageInput
onSend={onSendMessage} onSend={onSendMessage}
@ -70,5 +76,5 @@ export function ChatContainer({
selectedExpert={selectedExpert} selectedExpert={selectedExpert}
/> />
</div> </div>
) );
} }

View File

@ -1,145 +1,150 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react';
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react' import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts' import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
interface ExpertItem { interface ExpertItem {
name: string name: string;
description: string description: string;
source: string source: string;
path?: string path?: string;
body?: string body?: string;
disabled_in_scopes: string[] disabled_in_scopes: string[];
} }
interface SelectedExpert { interface SelectedExpert {
name: string name: string;
description: string description: string;
} }
interface ExpertSelectorProps { interface ExpertSelectorProps {
sessionId: string | null sessionId: string | null;
onManageExperts?: () => void onManageExperts?: () => void;
onSelectionChange?: (expert: SelectedExpert | null) => void onSelectionChange?: (expert: SelectedExpert | null) => void;
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */ /** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
settingsClosedTick?: number settingsClosedTick?: number;
} }
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) { export function ExpertSelector({
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null) sessionId,
const [expertList, setExpertList] = useState<ExpertItem[]>([]) onManageExperts,
const [open, setOpen] = useState(false) onSelectionChange,
const [loading, setLoading] = useState(false) settingsClosedTick,
const [listLoading, setListLoading] = useState(false) }: ExpertSelectorProps) {
const [error, setError] = useState<string | null>(null) const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null);
const [expertList, setExpertList] = useState<ExpertItem[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [listLoading, setListLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null);
// 刷新当前会话选中的专家(后端会对禁用专家返回 null // 刷新当前会话选中的专家(后端会对禁用专家返回 null
const refreshSelection = useCallback(() => { const refreshSelection = useCallback(() => {
if (!sessionId) { if (!sessionId) {
setSelectedExpert(null) setSelectedExpert(null);
onSelectionChange?.(null) onSelectionChange?.(null);
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
getSelectedExpert(sessionId) getSelectedExpert(sessionId)
.then(data => { .then((data) => {
if (data?.expert) { if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description }) setSelectedExpert({ name: data.expert.name, description: data.expert.description });
onSelectionChange?.({ name: data.expert.name, description: data.expert.description }) onSelectionChange?.({ name: data.expert.name, description: data.expert.description });
} else { } else {
// 已选专家被禁用/删除时,后端返回 null前端同步清除 // 已选专家被禁用/删除时,后端返回 null前端同步清除
setSelectedExpert(null) setSelectedExpert(null);
onSelectionChange?.(null) onSelectionChange?.(null);
} }
}) })
.catch(() => { .catch(() => {
// Silent fail: default to no expert // Silent fail: default to no expert
setSelectedExpert(null) setSelectedExpert(null);
onSelectionChange?.(null) onSelectionChange?.(null);
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false));
}, [sessionId, onSelectionChange]) }, [sessionId, onSelectionChange]);
// Load current selection whenever sessionId changes // Load current selection whenever sessionId changes
useEffect(() => { useEffect(() => {
refreshSelection() refreshSelection();
}, [refreshSelection]) }, [refreshSelection]);
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况) // 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
useEffect(() => { useEffect(() => {
if (settingsClosedTick === undefined) return if (settingsClosedTick === undefined) return;
refreshSelection() refreshSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick]) }, [settingsClosedTick]);
// Click outside to close dropdown // Click outside to close dropdown
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false) setOpen(false);
} }
} };
document.addEventListener('mousedown', handler) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler);
}, [open]) }, [open]);
const fetchExpertList = useCallback(async () => { const fetchExpertList = useCallback(async () => {
setListLoading(true) setListLoading(true);
const data = await listExperts() const data = await listExperts();
if (data) { if (data) {
// Only show enabled experts (disabled_in_scopes.length === 0) // Only show enabled experts (disabled_in_scopes.length === 0)
const enabled = (data.experts ?? []).filter( const enabled = (data.experts ?? []).filter(
(e: ExpertItem) => e.disabled_in_scopes.length === 0 (e: ExpertItem) => e.disabled_in_scopes.length === 0,
) );
setExpertList(enabled) setExpertList(enabled);
} }
setListLoading(false) setListLoading(false);
}, []) }, []);
const handleToggleOpen = () => { const handleToggleOpen = () => {
const next = !open const next = !open;
setOpen(next) setOpen(next);
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映 // 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
if (next) { if (next) {
fetchExpertList() fetchExpertList();
refreshSelection() refreshSelection();
} }
} };
const handleSelect = async (expert: SelectedExpert | null) => { const handleSelect = async (expert: SelectedExpert | null) => {
if (!sessionId) return if (!sessionId) return;
// Optimistic update // Optimistic update
const prev = selectedExpert const prev = selectedExpert;
setSelectedExpert(expert) setSelectedExpert(expert);
onSelectionChange?.(expert) onSelectionChange?.(expert);
setOpen(false) setOpen(false);
try { try {
const result = await selectExpert(sessionId, expert?.name ?? null) const result = await selectExpert(sessionId, expert?.name ?? null);
if (!result.success) { if (!result.success) {
// Revert // Revert
setSelectedExpert(prev) setSelectedExpert(prev);
onSelectionChange?.(prev) onSelectionChange?.(prev);
setError(result.error || '切换专家失败') setError(result.error || '切换专家失败');
setTimeout(() => setError(null), 3000) setTimeout(() => setError(null), 3000);
} }
} catch { } catch {
setSelectedExpert(prev) setSelectedExpert(prev);
onSelectionChange?.(prev) onSelectionChange?.(prev);
setError('网络错误,切换专家失败') setError('网络错误,切换专家失败');
setTimeout(() => setError(null), 3000) setTimeout(() => setError(null), 3000);
} }
} };
const handleManage = () => { const handleManage = () => {
setOpen(false) setOpen(false);
onManageExperts?.() onManageExperts?.();
} };
// If sessionId is null, render nothing // If sessionId is null, render nothing
if (!sessionId) return null if (!sessionId) return null;
return ( return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2"> <div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
@ -148,7 +153,9 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
onClick={handleToggleOpen} onClick={handleToggleOpen}
disabled={loading} disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50" className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'} title={
selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'
}
> >
{loading ? ( {loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
@ -175,9 +182,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
</button> </button>
{open && ( {open && (
<div <div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden">
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
>
{listLoading && expertList.length === 0 ? ( {listLoading && expertList.length === 0 ? (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]"> <div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> ... <Loader2 className="h-3.5 w-3.5 animate-spin" /> ...
@ -197,12 +202,14 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
</button> </button>
{expertList.length > 0 ? ( {expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]"> <div className="border-t border-[var(--border-color)]">
{expertList.map(expert => { {expertList.map((expert) => {
const isSelected = selectedExpert?.name === expert.name const isSelected = selectedExpert?.name === expert.name;
return ( return (
<button <button
key={expert.name} key={expert.name}
onClick={() => handleSelect({ name: expert.name, description: expert.description })} onClick={() =>
handleSelect({ name: expert.name, description: expert.description })
}
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors" className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
> >
<UserCheck <UserCheck
@ -230,7 +237,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
)} )}
</div> </div>
</button> </button>
) );
})} })}
</div> </div>
) : ( ) : (
@ -249,9 +256,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
</div> </div>
)} )}
</div> </div>
{error && ( {error && <span className="text-xs text-red-400 truncate">{error}</span>}
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,33 +1,45 @@
import { Send, Loader2, Square, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react' import {
import { useState, useRef, useEffect } from 'react' Send,
import type { Attachment } from '../../types/protocol' Loader2,
Square,
Sparkles,
Eye,
Paperclip,
X,
FileIcon,
ImageIcon,
MusicIcon,
VideoIcon,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import type { Attachment } from '../../types/protocol';
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
interface MessageInputProps { interface MessageInputProps {
onSend: (content: string, attachments: Attachment[]) => void onSend: (content: string, attachments: Attachment[]) => void;
onStop?: () => void onStop?: () => void;
disabled?: boolean disabled?: boolean;
isLoading?: boolean isLoading?: boolean;
placeholder?: string placeholder?: string;
isReadOnly?: boolean isReadOnly?: boolean;
channelName?: string channelName?: string;
selectedExpert?: { name: string; description: string } | null selectedExpert?: { name: string; description: string } | null;
} }
interface FileAttachment { interface FileAttachment {
id: string id: string;
file: File file: File;
attachment: Attachment attachment: Attachment;
preview?: string // 用于图片预览 preview?: string; // 用于图片预览
} }
// 根据 MIME 类型判断 media_type // 根据 MIME 类型判断 media_type
function getMediaType(mimeType: string): string { function getMediaType(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'image' if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio' if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video' if (mimeType.startsWith('video/')) return 'video';
return 'file' return 'file';
} }
export function MessageInput({ export function MessageInput({
@ -40,49 +52,50 @@ export function MessageInput({
channelName, channelName,
selectedExpert, selectedExpert,
}: MessageInputProps) { }: MessageInputProps) {
const effectivePlaceholder = placeholder const effectivePlaceholder =
?? (selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令') placeholder ??
const [content, setContent] = useState('') (selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令');
const [attachments, setAttachments] = useState<FileAttachment[]>([]) const [content, setContent] = useState('');
const [isDragging, setIsDragging] = useState(false) const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [error, setError] = useState<string | null>(null) const [isDragging, setIsDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null) const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null);
const wasLoadingRef = useRef(false) const fileInputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
useEffect(() => { useEffect(() => {
const textarea = textareaRef.current const textarea = textareaRef.current;
if (textarea) { if (textarea) {
textarea.style.height = 'auto' textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px` textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
} }
}, [content]) }, [content]);
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框 // 当 isLoading 从 true 变为 false 时,自动聚焦输入框
useEffect(() => { useEffect(() => {
if (wasLoadingRef.current && !isLoading && !isReadOnly) { if (wasLoadingRef.current && !isLoading && !isReadOnly) {
textareaRef.current?.focus() textareaRef.current?.focus();
} }
wasLoadingRef.current = isLoading wasLoadingRef.current = isLoading;
}, [isLoading, isReadOnly]) }, [isLoading, isReadOnly]);
// 处理文件选择 // 处理文件选择
const handleFileSelect = async (files: FileList | null) => { const handleFileSelect = async (files: FileList | null) => {
if (!files) return if (!files) return;
setError(null) setError(null);
const newAttachments: FileAttachment[] = [] const newAttachments: FileAttachment[] = [];
for (const file of Array.from(files)) { for (const file of Array.from(files)) {
// 检查文件大小 // 检查文件大小
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
setError(`文件 "${file.name}" 超过 50MB 限制`) setError(`文件 "${file.name}" 超过 50MB 限制`);
continue continue;
} }
// 读取文件为 base64 // 读取文件为 base64
const base64 = await readFileAsBase64(file) const base64 = await readFileAsBase64(file);
const mimeType = file.type || 'application/octet-stream' const mimeType = file.type || 'application/octet-stream';
const mediaType = getMediaType(mimeType) const mediaType = getMediaType(mimeType);
const attachment: Attachment = { const attachment: Attachment = {
path: file.name, path: file.name,
@ -90,78 +103,78 @@ export function MessageInput({
mime_type: mimeType, mime_type: mimeType,
content_base64: base64, content_base64: base64,
file_name: file.name, file_name: file.name,
} };
const fileAttachment: FileAttachment = { const fileAttachment: FileAttachment = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
file, file,
attachment, attachment,
preview: mediaType === 'image' ? base64 : undefined, preview: mediaType === 'image' ? base64 : undefined,
} };
newAttachments.push(fileAttachment) newAttachments.push(fileAttachment);
} }
setAttachments(prev => [...prev, ...newAttachments]) setAttachments((prev) => [...prev, ...newAttachments]);
} };
// 读取文件为 base64 // 读取文件为 base64
const readFileAsBase64 = (file: File): Promise<string> => { const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string const result = reader.result as string;
// 移除 data:xxx;base64, 前缀 // 移除 data:xxx;base64, 前缀
const base64 = result.split(',')[1] const base64 = result.split(',')[1];
resolve(base64) resolve(base64);
} };
reader.onerror = reject reader.onerror = reject;
reader.readAsDataURL(file) reader.readAsDataURL(file);
}) });
} };
// 点击附件按钮 // 点击附件按钮
const handleAttachClick = () => { const handleAttachClick = () => {
fileInputRef.current?.click() fileInputRef.current?.click();
} };
// 删除附件 // 删除附件
const handleRemoveAttachment = (index: number) => { const handleRemoveAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index)) setAttachments((prev) => prev.filter((_, i) => i !== index));
} };
// 粘贴事件处理 // 粘贴事件处理
const handlePaste = async (e: React.ClipboardEvent) => { const handlePaste = async (e: React.ClipboardEvent) => {
if (disabled || isReadOnly) return if (disabled || isReadOnly) return;
const clipboardData = e.clipboardData const clipboardData = e.clipboardData;
const items = clipboardData.items const items = clipboardData.items;
// 检查是否有文件(图片或其他文件) // 检查是否有文件(图片或其他文件)
const files: File[] = [] const files: File[] = [];
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile() const file = item.getAsFile();
if (file) { if (file) {
files.push(file) files.push(file);
} }
} }
} }
// 如果有文件,处理文件并阻止默认粘贴行为 // 如果有文件,处理文件并阻止默认粘贴行为
if (files.length > 0) { if (files.length > 0) {
e.preventDefault() e.preventDefault();
// 直接处理文件数组 // 直接处理文件数组
setError(null) setError(null);
for (const file of files) { for (const file of files) {
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
setError(`文件 "${file.name}" 超过 50MB 限制`) setError(`文件 "${file.name}" 超过 50MB 限制`);
continue continue;
} }
const base64 = await readFileAsBase64(file) const base64 = await readFileAsBase64(file);
const mimeType = file.type || 'application/octet-stream' const mimeType = file.type || 'application/octet-stream';
const mediaType = getMediaType(mimeType) const mediaType = getMediaType(mimeType);
const attachment: Attachment = { const attachment: Attachment = {
path: file.name, path: file.name,
@ -169,91 +182,91 @@ export function MessageInput({
mime_type: mimeType, mime_type: mimeType,
content_base64: base64, content_base64: base64,
file_name: file.name, file_name: file.name,
} };
const fileAttachment: FileAttachment = { const fileAttachment: FileAttachment = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
file, file,
attachment, attachment,
preview: mediaType === 'image' ? base64 : undefined, preview: mediaType === 'image' ? base64 : undefined,
} };
setAttachments(prev => [...prev, fileAttachment]) setAttachments((prev) => [...prev, fileAttachment]);
} }
} }
// 否则让默认的文本粘贴行为继续 // 否则让默认的文本粘贴行为继续
} };
// 拖拽事件 // 拖拽事件
const handleDragEnter = (e: React.DragEvent) => { const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
if (!disabled && !isReadOnly) { if (!disabled && !isReadOnly) {
setIsDragging(true) setIsDragging(true);
} }
} };
const handleDragLeave = (e: React.DragEvent) => { const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
// 检查是否真的离开了拖拽区域(而不是进入子元素) // 检查是否真的离开了拖拽区域(而不是进入子元素)
const relatedTarget = e.relatedTarget as Node | null const relatedTarget = e.relatedTarget as Node | null;
const currentTarget = e.currentTarget const currentTarget = e.currentTarget;
if (!relatedTarget || !currentTarget.contains(relatedTarget)) { if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setIsDragging(false) setIsDragging(false);
} }
} };
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
} };
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
setIsDragging(false) setIsDragging(false);
if (!disabled && !isReadOnly) { if (!disabled && !isReadOnly) {
handleFileSelect(e.dataTransfer.files) handleFileSelect(e.dataTransfer.files);
} }
} };
const handleSend = () => { const handleSend = () => {
const hasContent = content.trim() || attachments.length > 0 const hasContent = content.trim() || attachments.length > 0;
if (hasContent && !disabled && !isReadOnly) { if (hasContent && !disabled && !isReadOnly) {
onSend( onSend(
content.trim(), content.trim(),
attachments.map(a => a.attachment) attachments.map((a) => a.attachment),
) );
setContent('') setContent('');
setAttachments([]) setAttachments([]);
setError(null) setError(null);
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.style.height = 'auto' textareaRef.current.style.height = 'auto';
} }
} }
} };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault();
handleSend() handleSend();
} }
} };
// 获取附件图标 // 获取附件图标
const getAttachmentIcon = (mediaType: string) => { const getAttachmentIcon = (mediaType: string) => {
switch (mediaType) { switch (mediaType) {
case 'image': case 'image':
return <ImageIcon className="h-4 w-4" /> return <ImageIcon className="h-4 w-4" />;
case 'audio': case 'audio':
return <MusicIcon className="h-4 w-4" /> return <MusicIcon className="h-4 w-4" />;
case 'video': case 'video':
return <VideoIcon className="h-4 w-4" /> return <VideoIcon className="h-4 w-4" />;
default: default:
return <FileIcon className="h-4 w-4" /> return <FileIcon className="h-4 w-4" />;
} }
} };
// 只读模式:显示提示占位符 // 只读模式:显示提示占位符
if (isReadOnly) { if (isReadOnly) {
@ -273,14 +286,12 @@ export function MessageInput({
'当前通道仅支持查看历史消息' '当前通道仅支持查看历史消息'
)} )}
</p> </p>
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]"> WebSocket </p>
WebSocket
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
@ -337,9 +348,7 @@ export function MessageInput({
{/* 拖拽提示 */} {/* 拖拽提示 */}
{isDragging && ( {isDragging && (
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10"> <div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10">
<div className="text-[var(--accent-cyan)] text-sm font-medium"> <div className="text-[var(--accent-cyan)] text-sm font-medium"></div>
</div>
</div> </div>
)} )}
@ -405,5 +414,5 @@ export function MessageInput({
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,141 +1,147 @@
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react' import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
import { MessageBubble } from './MessageBubble' import { MessageBubble } from './MessageBubble';
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol';
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react' import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
interface MessageListProps { interface MessageListProps {
messages: ChatMessage[] messages: ChatMessage[];
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
showThinking?: boolean showThinking?: boolean;
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string viewKey?: string;
/** 高亮的消息 ID点击待办项后滚动并高亮显示 */ /** 高亮的消息 ID点击待办项后滚动并高亮显示 */
highlightedMessageId?: string | null highlightedMessageId?: string | null;
} }
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) { export function MessageList({
const bottomRef = useRef<HTMLDivElement>(null) messages,
const containerRef = useRef<HTMLDivElement>(null) onNavigateToSubAgent,
const isAtBottomRef = useRef(true) showThinking = true,
const prevShowBottomRef = useRef(false) viewKey,
const prevViewKeyRef = useRef(viewKey) highlightedMessageId,
const viewKeyRef = useRef(viewKey) }: MessageListProps) {
viewKeyRef.current = viewKey const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isAtBottomRef = useRef(true);
const prevShowBottomRef = useRef(false);
const prevViewKeyRef = useRef(viewKey);
const viewKeyRef = useRef(viewKey);
viewKeyRef.current = viewKey;
// Per-view scroll position memory // Per-view scroll position memory
const scrollPositionsRef = useRef<Map<string, number>>(new Map()) const scrollPositionsRef = useRef<Map<string, number>>(new Map());
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [newMessageCount, setNewMessageCount] = useState(0) const [newMessageCount, setNewMessageCount] = useState(0);
// ---- scroll helpers ---- // ---- scroll helpers ----
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
isAtBottomRef.current = true isAtBottomRef.current = true;
setShowScrollToBottom(false) setShowScrollToBottom(false);
setNewMessageCount(0) setNewMessageCount(0);
bottomRef.current?.scrollIntoView({ behavior }) bottomRef.current?.scrollIntoView({ behavior });
}, []) }, []);
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' }) containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}, []) }, []);
// ---- scroll event: track whether user is at bottom ---- // ---- scroll event: track whether user is at bottom ----
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = containerRef.current const el = containerRef.current;
if (!el) return if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const nearBottom = distanceFromBottom < 120 const nearBottom = distanceFromBottom < 120;
// Save scroll position for current view // Save scroll position for current view
const key = viewKeyRef.current const key = viewKeyRef.current;
if (key) { if (key) {
scrollPositionsRef.current.set(key, el.scrollTop) scrollPositionsRef.current.set(key, el.scrollTop);
} }
isAtBottomRef.current = nearBottom isAtBottomRef.current = nearBottom;
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部) // 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
const shouldShowBottom = distanceFromBottom > 200 const shouldShowBottom = distanceFromBottom > 200;
if (shouldShowBottom !== prevShowBottomRef.current) { if (shouldShowBottom !== prevShowBottomRef.current) {
prevShowBottomRef.current = shouldShowBottom prevShowBottomRef.current = shouldShowBottom;
setShowScrollToBottom(shouldShowBottom) setShowScrollToBottom(shouldShowBottom);
} }
// 滚回底部时清除新消息计数 // 滚回底部时清除新消息计数
if (nearBottom) { if (nearBottom) {
setNewMessageCount(0) setNewMessageCount(0);
} }
}, []) }, []);
// ---- auto-scroll: handle view switches and message updates ---- // ---- auto-scroll: handle view switches and message updates ----
useLayoutEffect(() => { useLayoutEffect(() => {
const prevKey = prevViewKeyRef.current const prevKey = prevViewKeyRef.current;
const viewChanged = prevKey !== viewKey const viewChanged = prevKey !== viewKey;
prevViewKeyRef.current = viewKey prevViewKeyRef.current = viewKey;
if (messages.length === 0) { if (messages.length === 0) {
isAtBottomRef.current = true isAtBottomRef.current = true;
return return;
} }
if (viewChanged) { if (viewChanged) {
// View switched (e.g. breadcrumb navigation): restore saved scroll position // View switched (e.g. breadcrumb navigation): restore saved scroll position
const key = viewKey ?? '' const key = viewKey ?? '';
const savedPos = scrollPositionsRef.current.get(key) const savedPos = scrollPositionsRef.current.get(key);
if (savedPos !== undefined && containerRef.current) { if (savedPos !== undefined && containerRef.current) {
containerRef.current.scrollTop = savedPos containerRef.current.scrollTop = savedPos;
const el = containerRef.current const el = containerRef.current;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
isAtBottomRef.current = distanceFromBottom < 120 isAtBottomRef.current = distanceFromBottom < 120;
return return;
} }
// First time viewing this view: scroll to bottom // First time viewing this view: scroll to bottom
isAtBottomRef.current = true isAtBottomRef.current = true;
bottomRef.current?.scrollIntoView({ behavior: 'instant' }) bottomRef.current?.scrollIntoView({ behavior: 'instant' });
return return;
} }
// Same view, messages changed: normal auto-scroll logic // Same view, messages changed: normal auto-scroll logic
const lastMessage = messages[messages.length - 1] const lastMessage = messages[messages.length - 1];
if (lastMessage.role === 'user' || isAtBottomRef.current) { if (lastMessage.role === 'user' || isAtBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'instant' }) bottomRef.current?.scrollIntoView({ behavior: 'instant' });
} else { } else {
setNewMessageCount((prev) => prev + 1) setNewMessageCount((prev) => prev + 1);
} }
}, [messages, viewKey]) }, [messages, viewKey]);
// ---- mount: always scroll to bottom if messages already loaded ---- // ---- mount: always scroll to bottom if messages already loaded ----
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {
scrollToBottom('instant') scrollToBottom('instant');
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, []);
// ---- highlight and scroll to todo message ---- // ---- highlight and scroll to todo message ----
useEffect(() => { useEffect(() => {
if (!highlightedMessageId) return if (!highlightedMessageId) return;
const container = containerRef.current const container = containerRef.current;
if (!container) return if (!container) return;
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`) const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
if (!targetElement) return if (!targetElement) return;
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetElement.classList.add('todo-highlight') targetElement.classList.add('todo-highlight');
setTimeout(() => { setTimeout(() => {
targetElement.classList.remove('todo-highlight') targetElement.classList.remove('todo-highlight');
}, 2000) }, 2000);
}, [highlightedMessageId]) }, [highlightedMessageId]);
// ---- empty state ---- // ---- empty state ----
@ -149,12 +155,16 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2> <h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]"></h2>
<p className="text-[var(--text-muted)]"> AI </p> <p className="text-[var(--text-muted)]"> AI </p>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]"> <div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/new </span> <span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/list </span> /new
</span>
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
/list
</span>
</div> </div>
</div> </div>
</div> </div>
) );
} }
// ---- main render ---- // ---- main render ----
@ -167,7 +177,12 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
className="h-full overflow-y-auto p-6 space-y-6" className="h-full overflow-y-auto p-6 space-y-6"
> >
{messages.map((message) => ( {messages.map((message) => (
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} /> <MessageBubble
key={message.id}
message={message}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
/>
))} ))}
<div ref={bottomRef} /> <div ref={bottomRef} />
</div> </div>
@ -205,7 +220,9 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
animate-fade-in" animate-fade-in"
aria-label="回到底部" aria-label="回到底部"
> >
<ArrowDown className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} /> <ArrowDown
className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`}
/>
{newMessageCount > 0 ? ( {newMessageCount > 0 ? (
<span className="text-sm font-medium text-[var(--text-primary)]"> <span className="text-sm font-medium text-[var(--text-primary)]">
{newMessageCount} {newMessageCount}
@ -223,5 +240,5 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@ -1,151 +1,157 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react';
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react' import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts' import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts';
import type { ModelOptionsResponse } from '../Settings/types' import type { ModelOptionsResponse } from '../Settings/types';
interface ModelSelectorProps { interface ModelSelectorProps {
sessionId: string | null sessionId: string | null;
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */ /** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
settingsClosedTick?: number settingsClosedTick?: number;
/** 选择变化回调(参数为生效的 provider/model未覆盖时为 current 默认) */ /** 选择变化回调(参数为生效的 provider/model未覆盖时为 current 默认) */
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
} }
export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange }: ModelSelectorProps) { export function ModelSelector({
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null) sessionId,
const [userProvider, setUserProvider] = useState<string | null>(null) settingsClosedTick,
const [userModel, setUserModel] = useState<string | null>(null) onSelectionChange,
const [open, setOpen] = useState(false) }: ModelSelectorProps) {
const [loading, setLoading] = useState(false) const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [saving, setSaving] = useState(false) const [userProvider, setUserProvider] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null) const [userModel, setUserModel] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交 // 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
const [draftProvider, setDraftProvider] = useState<string>('') const [draftProvider, setDraftProvider] = useState<string>('');
const [draftModel, setDraftModel] = useState<string>('') const [draftModel, setDraftModel] = useState<string>('');
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null);
// 刷新当前会话的用户模型覆盖 // 刷新当前会话的用户模型覆盖
const refreshSelection = useCallback(() => { const refreshSelection = useCallback(() => {
if (!sessionId) { if (!sessionId) {
setUserProvider(null) setUserProvider(null);
setUserModel(null) setUserModel(null);
return return;
} }
setLoading(true) setLoading(true);
setError(null) setError(null);
getSelectedModel(sessionId) getSelectedModel(sessionId)
.then(data => { .then((data) => {
setUserProvider(data.provider) setUserProvider(data.provider);
setUserModel(data.model) setUserModel(data.model);
}) })
.catch(() => { .catch(() => {
setUserProvider(null) setUserProvider(null);
setUserModel(null) setUserModel(null);
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false));
}, [sessionId]) }, [sessionId]);
// 加载模型选项(全局缓存,仅加载一次) // 加载模型选项(全局缓存,仅加载一次)
useEffect(() => { useEffect(() => {
if (modelOptions) return if (modelOptions) return;
listModelOptions().then(data => { listModelOptions().then((data) => {
if (data) setModelOptions(data) if (data) setModelOptions(data);
}) });
}, [modelOptions]) }, [modelOptions]);
// sessionId 变化时刷新用户选择 // sessionId 变化时刷新用户选择
useEffect(() => { useEffect(() => {
refreshSelection() refreshSelection();
}, [refreshSelection]) }, [refreshSelection]);
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更) // 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
useEffect(() => { useEffect(() => {
if (settingsClosedTick === undefined) return if (settingsClosedTick === undefined) return;
refreshSelection() refreshSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick]) }, [settingsClosedTick]);
// 计算生效模型并通知父组件 // 计算生效模型并通知父组件
const overridden = userProvider !== null || userModel !== null const overridden = userProvider !== null || userModel !== null;
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '' const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '';
const effectiveModel = userModel ?? modelOptions?.current.model ?? '' const effectiveModel = userModel ?? modelOptions?.current.model ?? '';
useEffect(() => { useEffect(() => {
if (!modelOptions) return if (!modelOptions) return;
onSelectionChange?.({ onSelectionChange?.({
provider: effectiveProvider, provider: effectiveProvider,
model: effectiveModel, model: effectiveModel,
overridden, overridden,
}) });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveProvider, effectiveModel, overridden, modelOptions]) }, [effectiveProvider, effectiveModel, overridden, modelOptions]);
// 点击外部关闭 dropdown // 点击外部关闭 dropdown
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return;
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false) setOpen(false);
} }
} };
document.addEventListener('mousedown', handler) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler);
}, [open]) }, [open]);
const handleToggleOpen = () => { const handleToggleOpen = () => {
const next = !open const next = !open;
setOpen(next) setOpen(next);
if (next) { if (next) {
// 打开时刷新选项与当前选择,同步草稿 // 打开时刷新选项与当前选择,同步草稿
if (!modelOptions) { if (!modelOptions) {
listModelOptions().then(data => { if (data) setModelOptions(data) }) listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
} }
refreshSelection() refreshSelection();
setDraftProvider(userProvider ?? '') setDraftProvider(userProvider ?? '');
setDraftModel(userModel ?? '') setDraftModel(userModel ?? '');
} }
} };
const handleApply = async () => { const handleApply = async () => {
if (!sessionId) return if (!sessionId) return;
const provider = draftProvider.trim() || null const provider = draftProvider.trim() || null;
const model = draftModel.trim() || null const model = draftModel.trim() || null;
setSaving(true) setSaving(true);
setError(null) setError(null);
try { try {
const result = await selectModel(sessionId, provider, model) const result = await selectModel(sessionId, provider, model);
if (!result.success) { if (!result.success) {
setError(result.error || '切换模型失败') setError(result.error || '切换模型失败');
setTimeout(() => setError(null), 3000) setTimeout(() => setError(null), 3000);
return return;
} }
setUserProvider(provider) setUserProvider(provider);
setUserModel(model) setUserModel(model);
setOpen(false) setOpen(false);
} catch { } catch {
setError('网络错误,切换模型失败') setError('网络错误,切换模型失败');
setTimeout(() => setError(null), 3000) setTimeout(() => setError(null), 3000);
} finally { } finally {
setSaving(false) setSaving(false);
} }
} };
const handleReset = () => { const handleReset = () => {
setDraftProvider('') setDraftProvider('');
setDraftModel('') setDraftModel('');
} };
if (!sessionId) return null if (!sessionId) return null;
// 草稿是否与已保存状态不同(用于启用"应用"按钮) // 草稿是否与已保存状态不同(用于启用"应用"按钮)
const draftChanged = const draftChanged =
(draftProvider || null) !== (userProvider ?? null) || (draftProvider || null) !== (userProvider ?? null) ||
(draftModel || null) !== (userModel ?? null) (draftModel || null) !== (userModel ?? null);
const buttonLabel = overridden const buttonLabel = overridden
? `${effectiveProvider}/${effectiveModel}` ? `${effectiveProvider}/${effectiveModel}`
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}` : `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`;
return ( return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2"> <div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
@ -154,7 +160,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
onClick={handleToggleOpen} onClick={handleToggleOpen}
disabled={loading} disabled={loading}
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50" className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`} title={
overridden
? `用户覆盖: ${effectiveProvider}/${effectiveModel}`
: `继承默认: ${effectiveProvider}/${effectiveModel}`
}
> >
{loading ? ( {loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
@ -163,7 +173,9 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`} className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
/> />
)} )}
<span className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}> <span
className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
>
{buttonLabel} {buttonLabel}
</span> </span>
<ChevronDown <ChevronDown
@ -172,9 +184,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
</button> </button>
{open && ( {open && (
<div <div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3">
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3"
>
<div className="text-xs text-[var(--text-muted)]"> <div className="text-xs text-[var(--text-muted)]">
{overridden {overridden
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)` ? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
@ -186,12 +196,16 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
Provider Provider
<select <select
value={draftProvider} value={draftProvider}
onChange={e => setDraftProvider(e.target.value)} onChange={(e) => setDraftProvider(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none" className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
> >
<option value="">{modelOptions ? `${modelOptions.current.provider}` : ''}</option> <option value="">
{(modelOptions?.providers ?? []).map(p => ( {modelOptions ? `${modelOptions.current.provider}` : ''}
<option key={p} value={p}>{p}</option> </option>
{(modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))} ))}
</select> </select>
</label> </label>
@ -200,20 +214,22 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
Model Model
<select <select
value={draftModel} value={draftModel}
onChange={e => setDraftModel(e.target.value)} onChange={(e) => setDraftModel(e.target.value)}
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none" className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
> >
<option value="">{modelOptions ? `${modelOptions.current.model}` : ''}</option> <option value="">
{(modelOptions?.models ?? []).map(m => ( {modelOptions ? `${modelOptions.current.model}` : ''}
<option key={m} value={m}>{m}</option> </option>
{(modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))} ))}
</select> </select>
</label> </label>
</div> </div>
{error && ( {error && <div className="text-xs text-red-400 truncate">{error}</div>}
<div className="text-xs text-red-400 truncate">{error}</div>
)}
<div className="flex items-center justify-between gap-2 pt-1"> <div className="flex items-center justify-between gap-2 pt-1">
<button <button
@ -232,7 +248,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
disabled={saving || !draftChanged} disabled={saving || !draftChanged}
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />} {saving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Check className="h-3 w-3" />
)}
</button> </button>
</div> </div>
@ -240,9 +260,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
</div> </div>
)} )}
</div> </div>
{error && ( {error && <span className="text-xs text-red-400 truncate">{error}</span>}
<span className="text-xs text-red-400 truncate">{error}</span>
)}
</div> </div>
) );
} }

View File

@ -1,34 +1,34 @@
import { useEffect } from 'react' import { useEffect } from 'react';
import { X, Terminal, Clock, Maximize2 } from 'lucide-react' import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm';
interface ToolDetailModalProps { interface ToolDetailModalProps {
toolName: string toolName: string;
status: string status: string;
statusLabel: string statusLabel: string;
arguments?: unknown arguments?: unknown;
resultContent: string resultContent: string;
callContent: string callContent: string;
durationMs?: number durationMs?: number;
onClose: () => void onClose: () => void;
} }
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms` if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000) const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000) const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m ${seconds}s` return `${minutes}m ${seconds}s`;
} }
function formatJSON(text: string): string { function formatJSON(text: string): string {
if (!text) return '' if (!text) return '';
try { try {
const parsed = JSON.parse(text) const parsed = JSON.parse(text);
return JSON.stringify(parsed, null, 2) return JSON.stringify(parsed, null, 2);
} catch { } catch {
return text return text;
} }
} }
@ -44,20 +44,23 @@ export function ToolDetailModal({
}: ToolDetailModalProps) { }: ToolDetailModalProps) {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose();
} };
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]) }, [onClose]);
const displayContent = resultContent || callContent const displayContent = resultContent || callContent;
const formattedContent = formatJSON(displayContent) const formattedContent = formatJSON(displayContent);
const statusColor = const statusColor =
status === 'calling' ? 'var(--accent-amber)' : status === 'calling'
status === 'result' ? 'var(--accent-green)' : ? 'var(--accent-amber)'
status === 'pending' ? '#f59e0b' : : status === 'result'
'var(--text-muted)' ? 'var(--accent-green)'
: status === 'pending'
? '#f59e0b'
: 'var(--text-muted)';
return ( return (
<div <div
@ -124,9 +127,7 @@ export function ToolDetailModal({
{resultContent ? '结果' : '输出'} {resultContent ? '结果' : '输出'}
</div> </div>
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content"> <div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>{formattedContent}</ReactMarkdown>
{formattedContent}
</ReactMarkdown>
</div> </div>
</div> </div>
)} )}
@ -142,5 +143,5 @@ export function ToolDetailModal({
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,8 +1,8 @@
import { Wifi, WifiOff, Loader2 } from 'lucide-react' import { Wifi, WifiOff, Loader2 } from 'lucide-react';
import type { ConnectionStatus } from '../types/protocol' import type { ConnectionStatus } from '../types/protocol';
interface ConnectionStatusProps { interface ConnectionStatusProps {
status: ConnectionStatus status: ConnectionStatus;
} }
export function ConnectionStatus({ status }: ConnectionStatusProps) { export function ConnectionStatus({ status }: ConnectionStatusProps) {
@ -13,36 +13,38 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) {
icon: <Loader2 className="h-3 w-3 animate-spin" />, icon: <Loader2 className="h-3 w-3 animate-spin" />,
text: '连接中', text: '连接中',
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30', className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
} };
case 'connected': case 'connected':
return { return {
icon: <Wifi className="h-3 w-3" />, icon: <Wifi className="h-3 w-3" />,
text: '已连接', text: '已连接',
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30', className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
} };
case 'disconnected': case 'disconnected':
return { return {
icon: <WifiOff className="h-3 w-3" />, icon: <WifiOff className="h-3 w-3" />,
text: '已断开', text: '已断开',
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30', className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
} };
case 'error': case 'error':
return { return {
icon: <WifiOff className="h-3 w-3" />, icon: <WifiOff className="h-3 w-3" />,
text: '连接错误', text: '连接错误',
className: 'text-red-400 bg-red-400/10 border-red-400/30', className: 'text-red-400 bg-red-400/10 border-red-400/30',
} };
} }
} };
const config = getStatusConfig() const config = getStatusConfig();
if (!config) return null if (!config) return null;
return ( return (
<div className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}> <div
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}
>
{config.icon} {config.icon}
<span>{config.text}</span> <span>{config.text}</span>
</div> </div>
) );
} }

View File

@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react' import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react';
import type { Channel } from '../../types/protocol' import type { Channel } from '../../types/protocol';
interface ChannelSelectorProps { interface ChannelSelectorProps {
channels: Channel[] channels: Channel[];
selectedChannel: string selectedChannel: string;
onSelectChannel: (channelId: string) => void onSelectChannel: (channelId: string) => void;
} }
const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> = { const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
@ -30,73 +30,78 @@ const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> =
icon: <Smartphone className="h-3.5 w-3.5" />, icon: <Smartphone className="h-3.5 w-3.5" />,
color: 'var(--accent-green)', color: 'var(--accent-green)',
}, },
} };
const DEFAULT_ICON = { const DEFAULT_ICON = {
icon: <MessageSquare className="h-3.5 w-3.5" />, icon: <MessageSquare className="h-3.5 w-3.5" />,
color: 'var(--text-muted)', color: 'var(--text-muted)',
} };
export function ChannelSelector({ export function ChannelSelector({
channels, channels,
selectedChannel, selectedChannel,
onSelectChannel, onSelectChannel,
}: ChannelSelectorProps) { }: ChannelSelectorProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }) const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({
const triggerRef = useRef<HTMLButtonElement>(null) top: 0,
const dropdownRef = useRef<HTMLDivElement>(null) right: 0,
const selected = channels.find((c) => c.id === selectedChannel) });
const iconConfig = selected ? (CHANNEL_ICONS[selected.id] || DEFAULT_ICON) : DEFAULT_ICON const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selected = channels.find((c) => c.id === selectedChannel);
const iconConfig = selected ? CHANNEL_ICONS[selected.id] || DEFAULT_ICON : DEFAULT_ICON;
// Calculate dropdown position when opening // Calculate dropdown position when opening
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (triggerRef.current) { if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect() const rect = triggerRef.current.getBoundingClientRect();
setDropdownPos({ setDropdownPos({
top: rect.bottom + 8, top: rect.bottom + 8,
right: window.innerWidth - rect.right, right: window.innerWidth - rect.right,
}) });
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
updatePosition() updatePosition();
window.addEventListener('resize', updatePosition) window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true) window.addEventListener('scroll', updatePosition, true);
return () => { return () => {
window.removeEventListener('resize', updatePosition) window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('scroll', updatePosition, true);
} };
} }
}, [isOpen, updatePosition]) }, [isOpen, updatePosition]);
// Close on outside click // Close on outside click
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return;
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const target = e.target as Node const target = e.target as Node;
if ( if (
dropdownRef.current && !dropdownRef.current.contains(target) && dropdownRef.current &&
triggerRef.current && !triggerRef.current.contains(target) !dropdownRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) { ) {
setIsOpen(false) setIsOpen(false);
} }
} };
document.addEventListener('mousedown', handleClick) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]) }, [isOpen]);
// Close on Escape // Close on Escape
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false) if (e.key === 'Escape') setIsOpen(false);
} };
document.addEventListener('keydown', handleKey) document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey);
}, [isOpen]) }, [isOpen]);
return ( return (
<> <>
@ -107,9 +112,10 @@ export function ChannelSelector({
className={` className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
transition-all duration-200 ease-out transition-all duration-200 ease-out
${isOpen ${
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' isOpen
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]' ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
} }
`} `}
> >
@ -131,7 +137,9 @@ export function ChannelSelector({
{selected ? ( {selected ? (
<span <span
className={`h-1.5 w-1.5 rounded-full ${ className={`h-1.5 w-1.5 rounded-full ${
selected.isWritable ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]' : 'bg-zinc-500' selected.isWritable
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
: 'bg-zinc-500'
}`} }`}
/> />
) : ( ) : (
@ -146,115 +154,117 @@ export function ChannelSelector({
</button> </button>
{/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */} {/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */}
{isOpen && createPortal( {isOpen &&
<div createPortal(
ref={dropdownRef}
className="fixed z-[9999] w-56 animate-slide-in"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
>
<div <div
className=" ref={dropdownRef}
className="fixed z-[9999] w-56 animate-slide-in"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
>
<div
className="
rounded-xl border border-[var(--border-color)] rounded-xl border border-[var(--border-color)]
bg-[var(--bg-secondary)]/95 backdrop-blur-xl bg-[var(--bg-secondary)]/95 backdrop-blur-xl
shadow-2xl shadow-black/40 shadow-2xl shadow-black/40
overflow-hidden overflow-hidden
" "
> >
{/* Channel List */} {/* Channel List */}
<div className="py-1"> <div className="py-1">
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-[var(--text-muted)]"> <div className="px-4 py-6 text-center text-xs text-[var(--text-muted)]">
</div> </div>
) : ( ) : (
channels.map((channel, index) => { channels.map((channel, index) => {
const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON;
const isActive = channel.id === selectedChannel const isActive = channel.id === selectedChannel;
return ( return (
<button <button
key={channel.id} key={channel.id}
onClick={() => { onClick={() => {
onSelectChannel(channel.id) onSelectChannel(channel.id);
setIsOpen(false) setIsOpen(false);
}} }}
className={` className={`
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
transition-all duration-150 transition-all duration-150
hover:bg-[var(--overlay-hover)] hover:bg-[var(--overlay-hover)]
${isActive ? 'bg-[var(--overlay-subtle)]' : ''} ${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
`} `}
style={{ style={{
animationDelay: `${index * 40}ms`, animationDelay: `${index * 40}ms`,
animation: 'fade-in 0.2s ease-out both', animation: 'fade-in 0.2s ease-out both',
}} }}
> >
{/* Left accent bar */} {/* Left accent bar */}
<div <div
className={` className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200 transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'} ${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
`} `}
/> />
{/* Icon */} {/* Icon */}
<span <span
className={` className={`
flex-shrink-0 transition-colors duration-200 flex-shrink-0 transition-colors duration-200
${isActive ? 'opacity-100' : 'opacity-60 group-hover/item:opacity-100'} ${isActive ? 'opacity-100' : 'opacity-60 group-hover/item:opacity-100'}
`} `}
style={{ color: cfg.color }} style={{ color: cfg.color }}
>
{cfg.icon}
</span>
{/* Name + Description */}
<div className="flex-1 min-w-0">
<div
className={`text-sm truncate transition-colors duration-200 ${
isActive
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
}`}
> >
{channel.name} {cfg.icon}
</div> </span>
{channel.description && (
<div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">
{channel.description}
</div>
)}
</div>
{/* Writable badge */} {/* Name + Description */}
<span <div className="flex-1 min-w-0">
className={` <div
className={`text-sm truncate transition-colors duration-200 ${
isActive
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
}`}
>
{channel.name}
</div>
{channel.description && (
<div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">
{channel.description}
</div>
)}
</div>
{/* Writable badge */}
<span
className={`
flex-shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] flex-shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]
transition-all duration-200 transition-all duration-200
${channel.isWritable ${
? 'bg-emerald-400/10 text-emerald-400' channel.isWritable
: 'bg-zinc-500/10 text-zinc-500' ? 'bg-emerald-400/10 text-emerald-400'
: 'bg-zinc-500/10 text-zinc-500'
} }
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''} ${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
`} `}
> >
{channel.isWritable ? ( {channel.isWritable ? (
<Pencil className="h-2.5 w-2.5" /> <Pencil className="h-2.5 w-2.5" />
) : ( ) : (
<Eye className="h-2.5 w-2.5" /> <Eye className="h-2.5 w-2.5" />
)} )}
<span>{channel.isWritable ? '可写' : '只读'}</span> <span>{channel.isWritable ? '可写' : '只读'}</span>
</span> </span>
</button> </button>
) );
}) })
)} )}
</div>
</div> </div>
</div> </div>,
</div>, document.body,
document.body )}
)}
</> </>
) );
} }

View File

@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { MessageSquare, ChevronDown } from 'lucide-react' import { MessageSquare, ChevronDown } from 'lucide-react';
import type { SessionSummary } from '../../types/protocol' import type { SessionSummary } from '../../types/protocol';
interface SessionSelectorProps { interface SessionSelectorProps {
sessions: SessionSummary[] sessions: SessionSummary[];
selectedSessionId: string | null selectedSessionId: string | null;
onSelectSession: (sessionId: string) => void onSelectSession: (sessionId: string) => void;
} }
export function SessionSelector({ export function SessionSelector({
@ -14,59 +14,64 @@ export function SessionSelector({
selectedSessionId, selectedSessionId,
onSelectSession, onSelectSession,
}: SessionSelectorProps) { }: SessionSelectorProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 }) const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({
const triggerRef = useRef<HTMLButtonElement>(null) top: 0,
const dropdownRef = useRef<HTMLDivElement>(null) right: 0,
const selected = sessions.find((s) => s.session_id === selectedSessionId) });
const triggerRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selected = sessions.find((s) => s.session_id === selectedSessionId);
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (triggerRef.current) { if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect() const rect = triggerRef.current.getBoundingClientRect();
setDropdownPos({ setDropdownPos({
top: rect.bottom + 8, top: rect.bottom + 8,
right: window.innerWidth - rect.right, right: window.innerWidth - rect.right,
}) });
} }
}, []) }, []);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
updatePosition() updatePosition();
window.addEventListener('resize', updatePosition) window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true) window.addEventListener('scroll', updatePosition, true);
return () => { return () => {
window.removeEventListener('resize', updatePosition) window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('scroll', updatePosition, true);
} };
} }
}, [isOpen, updatePosition]) }, [isOpen, updatePosition]);
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return;
const handleClick = (e: MouseEvent) => { const handleClick = (e: MouseEvent) => {
const target = e.target as Node const target = e.target as Node;
if ( if (
dropdownRef.current && !dropdownRef.current.contains(target) && dropdownRef.current &&
triggerRef.current && !triggerRef.current.contains(target) !dropdownRef.current.contains(target) &&
triggerRef.current &&
!triggerRef.current.contains(target)
) { ) {
setIsOpen(false) setIsOpen(false);
} }
} };
document.addEventListener('mousedown', handleClick) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen]) }, [isOpen]);
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false) if (e.key === 'Escape') setIsOpen(false);
} };
document.addEventListener('keydown', handleKey) document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey) return () => document.removeEventListener('keydown', handleKey);
}, [isOpen]) }, [isOpen]);
if (sessions.length === 0) return null if (sessions.length === 0) return null;
return ( return (
<> <>
@ -77,11 +82,12 @@ export function SessionSelector({
className={` className={`
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
transition-all duration-200 ease-out transition-all duration-200 ease-out
${sessions.length <= 1 ${
? 'border-[var(--border-color)] cursor-default' sessions.length <= 1
: isOpen ? 'border-[var(--border-color)] cursor-default'
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]' : isOpen
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer' ? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
} }
`} `}
> >
@ -104,73 +110,79 @@ export function SessionSelector({
)} )}
</button> </button>
{isOpen && sessions.length > 1 && createPortal( {isOpen &&
<div sessions.length > 1 &&
ref={dropdownRef} createPortal(
className="fixed z-[9999] w-56 animate-slide-in"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
>
<div <div
className=" ref={dropdownRef}
className="fixed z-[9999] w-56 animate-slide-in"
style={{ top: dropdownPos.top, right: dropdownPos.right }}
>
<div
className="
rounded-xl border border-[var(--border-color)] rounded-xl border border-[var(--border-color)]
bg-[var(--bg-secondary)]/95 backdrop-blur-xl bg-[var(--bg-secondary)]/95 backdrop-blur-xl
shadow-2xl shadow-black/40 shadow-2xl shadow-black/40
overflow-hidden overflow-hidden
" "
> >
<div className="py-1 max-h-60 overflow-y-auto"> <div className="py-1 max-h-60 overflow-y-auto">
{sessions.map((s, index) => { {sessions.map((s, index) => {
const isActive = s.session_id === selectedSessionId const isActive = s.session_id === selectedSessionId;
return ( return (
<button <button
key={s.session_id} key={s.session_id}
onClick={() => { onClick={() => {
onSelectSession(s.session_id) onSelectSession(s.session_id);
setIsOpen(false) setIsOpen(false);
}} }}
className={` className={`
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
transition-all duration-150 transition-all duration-150
hover:bg-[var(--overlay-hover)] hover:bg-[var(--overlay-hover)]
${isActive ? 'bg-[var(--overlay-subtle)]' : ''} ${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
`} `}
style={{ style={{
animationDelay: `${index * 40}ms`, animationDelay: `${index * 40}ms`,
animation: 'fade-in 0.2s ease-out both', animation: 'fade-in 0.2s ease-out both',
}} }}
> >
<div <div
className={` className={`
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
transition-all duration-200 transition-all duration-200
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'} ${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
`} `}
/> />
<MessageSquare <MessageSquare
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${ className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]' isActive
}`} ? 'text-[var(--accent-cyan)]'
/> : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
<div className="flex-1 min-w-0">
<div
className={`text-sm truncate transition-colors duration-200 ${
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
}`} }`}
> />
{s.title} <div className="flex-1 min-w-0">
<div
className={`text-sm truncate transition-colors duration-200 ${
isActive
? 'text-[var(--text-primary)] font-medium'
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
}`}
>
{s.title}
</div>
</div> </div>
</div> <span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0"> {s.message_count}
{s.message_count} </span>
</span> </button>
</button> );
) })}
})} </div>
</div> </div>
</div> </div>,
</div>, document.body,
document.body )}
)}
</> </>
) );
} }

View File

@ -1,80 +1,167 @@
import { useState } from 'react' import { useState } from 'react';
import { Brain, User, Library, History, Cpu, Globe, Star, Package, RefreshCw, X, ChevronDown, ChevronRight, Plus, Pencil, Trash2, Check } from 'lucide-react' import {
import type { MemorySummary, Command } from '../../types/protocol' Brain,
User,
Library,
History,
Cpu,
Globe,
Star,
Package,
RefreshCw,
X,
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Check,
} from 'lucide-react';
import type { MemorySummary, Command } from '../../types/protocol';
/* ── types ────────────────────────────────────────────── */ /* ── types ────────────────────────────────────────────── */
interface MemoryPanelProps { interface MemoryPanelProps {
memories: MemorySummary[] memories: MemorySummary[];
onRefresh: () => void onRefresh: () => void;
onClose?: () => void onClose?: () => void;
onCreateMemory: (ns: string, key: string, content: string) => Command onCreateMemory: (ns: string, key: string, content: string) => Command;
onUpdateMemory: (id: string, content: string) => Command onUpdateMemory: (id: string, content: string) => Command;
onDeleteMemory: (id: string) => Command onDeleteMemory: (id: string) => Command;
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => void;
} }
interface NamespaceConfig { label: string; icon: typeof Brain; accent: string; accentBorder: string } interface NamespaceConfig {
label: string;
icon: typeof Brain;
accent: string;
accentBorder: string;
}
const NS: Record<string, NamespaceConfig> = { const NS: Record<string, NamespaceConfig> = {
user: { label: '用户记忆', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-400/40' }, user: {
semantic: { label: '语义记忆', icon: Library, accent: 'text-amber-400', accentBorder: 'border-amber-400/40' }, label: '用户记忆',
episodic: { label: '情景记忆', icon: History, accent: 'text-purple-400', accentBorder: 'border-purple-400/40' }, icon: User,
skill: { label: '技能记忆', icon: Cpu, accent: 'text-green-400', accentBorder: 'border-green-400/40' }, accent: 'text-cyan-400',
environment: { label: '环境记忆', icon: Globe, accent: 'text-sky-400', accentBorder: 'border-sky-400/40' }, accentBorder: 'border-cyan-400/40',
reflection: { label: '反思记忆', icon: Star, accent: 'text-rose-400', accentBorder: 'border-rose-400/40' }, },
other: { label: '其他', icon: Package, accent: 'text-stone-400', accentBorder: 'border-stone-400/40' }, semantic: {
} label: '语义记忆',
icon: Library,
accent: 'text-amber-400',
accentBorder: 'border-amber-400/40',
},
episodic: {
label: '情景记忆',
icon: History,
accent: 'text-purple-400',
accentBorder: 'border-purple-400/40',
},
skill: {
label: '技能记忆',
icon: Cpu,
accent: 'text-green-400',
accentBorder: 'border-green-400/40',
},
environment: {
label: '环境记忆',
icon: Globe,
accent: 'text-sky-400',
accentBorder: 'border-sky-400/40',
},
reflection: {
label: '反思记忆',
icon: Star,
accent: 'text-rose-400',
accentBorder: 'border-rose-400/40',
},
other: {
label: '其他',
icon: Package,
accent: 'text-stone-400',
accentBorder: 'border-stone-400/40',
},
};
function cfg(ns: string): NamespaceConfig { function cfg(ns: string): NamespaceConfig {
return NS[ns] ?? { label: ns, icon: Package, accent: 'text-[var(--text-secondary)]', accentBorder: 'border-[var(--border-color)]' } return (
NS[ns] ?? {
label: ns,
icon: Package,
accent: 'text-[var(--text-secondary)]',
accentBorder: 'border-[var(--border-color)]',
}
);
} }
function fmtKey(k: string) { return k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) } function fmtKey(k: string) {
return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
const NS_OPTIONS = Object.entries(NS) const NS_OPTIONS = Object.entries(NS);
/* ── Memory card with edit/delete ──────────────────────── */ /* ── Memory card with edit/delete ──────────────────────── */
function MemoryCard({ memory, config, onUpdate, onDelete }: function MemoryCard({
{ memory: MemorySummary; config: NamespaceConfig; onUpdate: (id: string, content: string) => void; onDelete: (id: string) => void }) { memory,
config,
const [editing, setEditing] = useState(false) onUpdate,
const [editContent, setEditContent] = useState(memory.content) onDelete,
const [confirmDelete, setConfirmDelete] = useState(false) }: {
memory: MemorySummary;
config: NamespaceConfig;
onUpdate: (id: string, content: string) => void;
onDelete: (id: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState(memory.content);
const [confirmDelete, setConfirmDelete] = useState(false);
const handleSave = () => { const handleSave = () => {
if (editContent.trim() && editContent !== memory.content) { if (editContent.trim() && editContent !== memory.content) {
onUpdate(memory.id, editContent.trim()) onUpdate(memory.id, editContent.trim());
} }
setEditing(false) setEditing(false);
} };
const handleDelete = () => { const handleDelete = () => {
if (confirmDelete) { if (confirmDelete) {
onDelete(memory.id) onDelete(memory.id);
} else { } else {
setConfirmDelete(true) setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000) setTimeout(() => setConfirmDelete(false), 3000);
} }
} };
return ( return (
<div className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}> <div
className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}
>
<div className="px-3 py-2.5"> <div className="px-3 py-2.5">
{/* header row */} {/* header row */}
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}> <span
className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}
>
{fmtKey(memory.memory_key)} {fmtKey(memory.memory_key)}
</span> </span>
{/* action buttons — visible on hover */} {/* action buttons — visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => { setEditing(!editing); setEditContent(memory.content) }} <button
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`} title="编辑"> onClick={() => {
setEditing(!editing);
setEditContent(memory.content);
}}
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`}
title="编辑"
>
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
</button> </button>
<button onClick={handleDelete} <button
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`} title={confirmDelete ? '再次点击确认删除' : '删除'}> onClick={handleDelete}
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`}
title={confirmDelete ? '再次点击确认删除' : '删除'}
>
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</button> </button>
</div> </div>
@ -83,11 +170,23 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
{/* content */} {/* content */}
{editing ? ( {editing ? (
<div className="flex gap-1.5 mt-1"> <div className="flex gap-1.5 mt-1">
<textarea value={editContent} onChange={e => setEditContent(e.target.value)} <textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]" className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]"
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave() } }} /> autoFocus
<button onClick={handleSave} onKeyDown={(e) => {
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors" title="保存"> if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSave();
}
}}
/>
<button
onClick={handleSave}
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors"
title="保存"
>
<Check className="h-3.5 w-3.5" /> <Check className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@ -96,95 +195,180 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
)} )}
</div> </div>
</div> </div>
) );
} }
/* ── Add memory form ───────────────────────────────────── */ /* ── Add memory form ───────────────────────────────────── */
function AddMemoryForm({ onAdd, onCancel }: { onAdd: (ns: string, key: string, content: string) => void; onCancel: () => void }) { function AddMemoryForm({
const [ns, setNs] = useState('user') onAdd,
const [key, setKey] = useState('') onCancel,
const [content, setContent] = useState('') }: {
onAdd: (ns: string, key: string, content: string) => void;
onCancel: () => void;
}) {
const [ns, setNs] = useState('user');
const [key, setKey] = useState('');
const [content, setContent] = useState('');
const handleSubmit = () => { const handleSubmit = () => {
if (!key.trim() || !content.trim()) return if (!key.trim() || !content.trim()) return;
onAdd(ns, key.trim(), content.trim()) onAdd(ns, key.trim(), content.trim());
} };
return ( return (
<div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in"> <div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in">
<div className="flex gap-2"> <div className="flex gap-2">
<select value={ns} onChange={e => setNs(e.target.value)} <select
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]"> value={ns}
{NS_OPTIONS.map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} onChange={(e) => setNs(e.target.value)}
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]"
>
{NS_OPTIONS.map(([k, v]) => (
<option key={k} value={k}>
{v.label}
</option>
))}
</select> </select>
<input value={key} onChange={e => setKey(e.target.value)} placeholder="键名 (如 work_preference)" <input
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]" /> value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="键名 (如 work_preference)"
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
/>
</div> </div>
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="内容..." <textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="内容..."
className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]" className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]"
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() } }} /> autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
<button onClick={onCancel} className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors"></button> <button
<button onClick={handleSubmit} disabled={!key.trim() || !content.trim()} onClick={onCancel}
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"></button> className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors"
>
</button>
<button
onClick={handleSubmit}
disabled={!key.trim() || !content.trim()}
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
</div> </div>
</div> </div>
) );
} }
/* ── Section header ────────────────────────────────────── */ /* ── Section header ────────────────────────────────────── */
function SectionHeader({ config, count, isCollapsed, onClick }: function SectionHeader({
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) { config,
const Icon = config.icon count,
isCollapsed,
onClick,
}: {
config: NamespaceConfig;
count: number;
isCollapsed: boolean;
onClick: () => void;
}) {
const Icon = config.icon;
return ( return (
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"> <button
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />} onClick={onClick}
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}> className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
) : (
<ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
)}
<div
className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}
>
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
</div> </div>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span> <span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span> {config.label}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
{count}
</span>
</button> </button>
) );
} }
/* ── main component ────────────────────────────────────── */ /* ── main component ────────────────────────────────────── */
export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUpdateMemory, onDeleteMemory, sendCommand }: MemoryPanelProps) { export function MemoryPanel({
memories,
onRefresh,
onClose,
onCreateMemory,
onUpdateMemory,
onDeleteMemory,
sendCommand,
}: MemoryPanelProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => { const [collapsed, setCollapsed] = useState<Set<string>>(() => {
try { const s = localStorage.getItem('picobot-memory-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() } try {
catch (_) { return new Set() } const s = localStorage.getItem('picobot-memory-collapsed');
}) return s ? new Set(JSON.parse(s)) : new Set();
const [showAddForm, setShowAddForm] = useState(false) } catch (_) {
return new Set();
}
});
const [showAddForm, setShowAddForm] = useState(false);
const toggle = (ns: string) => { const toggle = (ns: string) => {
setCollapsed(prev => { setCollapsed((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(ns)) { next.delete(ns) } else { next.add(ns) } if (next.has(ns)) {
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next])) next.delete(ns);
return next } else {
}) next.add(ns);
}
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]));
return next;
});
};
const grouped = new Map<string, MemorySummary[]>();
for (const m of memories) {
const l = grouped.get(m.namespace) || [];
l.push(m);
grouped.set(m.namespace, l);
} }
const grouped = new Map<string, MemorySummary[]>() const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other'];
for (const m of memories) { const l = grouped.get(m.namespace) || []; l.push(m); grouped.set(m.namespace, l) }
const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other']
const sorted = Array.from(grouped.keys()).sort((a, b) => { const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a); const bi = order.indexOf(b) const ai = order.indexOf(a);
if (ai !== -1 && bi !== -1) return ai - bi const bi = order.indexOf(b);
if (ai !== -1) return -1; if (bi !== -1) return 1 if (ai !== -1 && bi !== -1) return ai - bi;
return a.localeCompare(b) if (ai !== -1) return -1;
}) if (bi !== -1) return 1;
return a.localeCompare(b);
});
const handleCreate = (ns: string, key: string, content: string) => { const handleCreate = (ns: string, key: string, content: string) => {
sendCommand(onCreateMemory(ns, key, content)) sendCommand(onCreateMemory(ns, key, content));
setShowAddForm(false) setShowAddForm(false);
} };
const handleUpdate = (id: string, content: string) => { sendCommand(onUpdateMemory(id, content)) } const handleUpdate = (id: string, content: string) => {
const handleDelete = (id: string) => { sendCommand(onDeleteMemory(id)) } sendCommand(onUpdateMemory(id, content));
};
const handleDelete = (id: string) => {
sendCommand(onDeleteMemory(id));
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -194,20 +378,44 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
<Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" /> <Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
</div> </div>
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight"></span> <span className="text-sm font-bold text-[var(--text-primary)] tracking-tight"></span>
{memories.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{memories.length}</span>} {memories.length > 0 && (
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
{memories.length}
</span>
)}
<div className="ml-auto flex items-center gap-0.5"> <div className="ml-auto flex items-center gap-0.5">
<button onClick={() => setShowAddForm(!showAddForm)} className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`} title="新增记忆"> <button
onClick={() => setShowAddForm(!showAddForm)}
className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`}
title="新增记忆"
>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</button> </button>
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新"> <button
onClick={onRefresh}
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
title="刷新"
>
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
{onClose && <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起"><X className="h-3.5 w-3.5" /></button>} {onClose && (
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
title="收起"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
</div> </div>
{/* add form */} {/* add form */}
{showAddForm && <div className="px-3 pt-2"><AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} /></div>} {showAddForm && (
<div className="px-3 pt-2">
<AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} />
</div>
)}
{/* empty */} {/* empty */}
{memories.length === 0 && !showAddForm && ( {memories.length === 0 && !showAddForm && (
@ -217,7 +425,11 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" /> <div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" /> <Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
</div> </div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed">PicoBot <br /></p> <p className="text-sm text-[var(--text-muted)] leading-relaxed">
PicoBot
<br />
</p>
</div> </div>
</div> </div>
)} )}
@ -225,23 +437,36 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
{/* list */} {/* list */}
{memories.length > 0 && ( {memories.length > 0 && (
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3"> <div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
{sorted.map(ns => { {sorted.map((ns) => {
const c = cfg(ns) const c = cfg(ns);
const items = grouped.get(ns)! const items = grouped.get(ns)!;
const closed = collapsed.has(ns) const closed = collapsed.has(ns);
return ( return (
<div key={ns}> <div key={ns}>
<SectionHeader config={c} count={items.length} isCollapsed={closed} onClick={() => toggle(ns)} /> <SectionHeader
config={c}
count={items.length}
isCollapsed={closed}
onClick={() => toggle(ns)}
/>
{!closed && ( {!closed && (
<div className="mt-1.5 space-y-1.5"> <div className="mt-1.5 space-y-1.5">
{items.map(m => <MemoryCard key={m.id} memory={m} config={c} onUpdate={handleUpdate} onDelete={handleDelete} />)} {items.map((m) => (
<MemoryCard
key={m.id}
memory={m}
config={c}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div> </div>
)} )}
</div> </div>
) );
})} })}
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@ -1,48 +1,84 @@
import { useState } from 'react' import { useState } from 'react';
import { Package, User, Folder, RefreshCw, ChevronDown, ChevronRight, BookOpen } from 'lucide-react' import {
import type { SkillSummary } from '../../types/protocol' Package,
User,
Folder,
RefreshCw,
ChevronDown,
ChevronRight,
BookOpen,
} from 'lucide-react';
import type { SkillSummary } from '../../types/protocol';
/* ── types ────────────────────────────────────────────── */ /* ── types ────────────────────────────────────────────── */
interface SkillListProps { interface SkillListProps {
skills: SkillSummary[] skills: SkillSummary[];
onRefresh: () => void onRefresh: () => void;
} }
interface SourceConfig { interface SourceConfig {
label: string label: string;
icon: typeof Package icon: typeof Package;
accent: string accent: string;
} }
const SOURCE_CONFIG: Record<string, SourceConfig> = { const SOURCE_CONFIG: Record<string, SourceConfig> = {
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' }, user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' }, useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
useropenclaw:{ label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' }, useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' }, project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
projectagent:{ label: '项目 Agent', icon: Folder, accent: 'text-amber-400' }, projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' }, projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' },
} };
function sourceConfig(source: string): SourceConfig { function sourceConfig(source: string): SourceConfig {
return SOURCE_CONFIG[source] ?? { label: source, icon: Package, accent: 'text-[var(--text-secondary)]' } return (
SOURCE_CONFIG[source] ?? {
label: source,
icon: Package,
accent: 'text-[var(--text-secondary)]',
}
);
} }
/* ── Section header ────────────────────────────────────── */ /* ── Section header ────────────────────────────────────── */
function SectionHeader({ config, count, isCollapsed, onClick }: function SectionHeader({
{ config: SourceConfig; count: number; isCollapsed: boolean; onClick: () => void }) { config,
const Icon = config.icon count,
isCollapsed,
onClick,
}: {
config: SourceConfig;
count: number;
isCollapsed: boolean;
onClick: () => void;
}) {
const Icon = config.icon;
return ( return (
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"> <button
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />} onClick={onClick}
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}> className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"
>
{isCollapsed ? (
<ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
) : (
<ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
)}
<div
className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}
>
<Icon className="h-3 w-3" /> <Icon className="h-3 w-3" />
</div> </div>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span> <span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span> {config.label}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
{count}
</span>
</button> </button>
) );
} }
/* ── Skill card ────────────────────────────────────────── */ /* ── Skill card ────────────────────────────────────────── */
@ -54,39 +90,55 @@ function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfi
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}> <span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}>
{skill.name} {skill.name}
</span> </span>
<p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">{skill.description}</p> <p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">
{skill.description}
</p>
</div> </div>
</div> </div>
) );
} }
/* ── main component ────────────────────────────────────── */ /* ── main component ────────────────────────────────────── */
export function SkillList({ skills, onRefresh }: SkillListProps) { export function SkillList({ skills, onRefresh }: SkillListProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => { const [collapsed, setCollapsed] = useState<Set<string>>(() => {
try { const s = localStorage.getItem('picobot-skill-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() } try {
catch (_) { return new Set() } const s = localStorage.getItem('picobot-skill-collapsed');
}) return s ? new Set(JSON.parse(s)) : new Set();
} catch (_) {
return new Set();
}
});
const toggle = (source: string) => { const toggle = (source: string) => {
setCollapsed(prev => { setCollapsed((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(source)) { next.delete(source) } else { next.add(source) } if (next.has(source)) {
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next])) next.delete(source);
return next } else {
}) next.add(source);
}
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next]));
return next;
});
};
const grouped = new Map<string, SkillSummary[]>();
for (const s of skills) {
const l = grouped.get(s.source) || [];
l.push(s);
grouped.set(s.source, l);
} }
const grouped = new Map<string, SkillSummary[]>() const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw'];
for (const s of skills) { const l = grouped.get(s.source) || []; l.push(s); grouped.set(s.source, l) }
const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw']
const sorted = Array.from(grouped.keys()).sort((a, b) => { const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a); const bi = order.indexOf(b) const ai = order.indexOf(a);
if (ai !== -1 && bi !== -1) return ai - bi const bi = order.indexOf(b);
if (ai !== -1) return -1; if (bi !== -1) return 1 if (ai !== -1 && bi !== -1) return ai - bi;
return a.localeCompare(b) if (ai !== -1) return -1;
}) if (bi !== -1) return 1;
return a.localeCompare(b);
});
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -96,9 +148,17 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
<BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" /> <BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
</div> </div>
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight"></span> <span className="text-sm font-bold text-[var(--text-primary)] tracking-tight"></span>
{skills.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{skills.length}</span>} {skills.length > 0 && (
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
{skills.length}
</span>
)}
<div className="ml-auto flex items-center gap-0.5"> <div className="ml-auto flex items-center gap-0.5">
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新"> <button
onClick={onRefresh}
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
title="刷新"
>
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@ -112,7 +172,10 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" /> <div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" /> <BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
</div> </div>
<p className="text-sm text-[var(--text-muted)] leading-relaxed"><br /> .picobot/skills/ SKILL.md </p> <p className="text-sm text-[var(--text-muted)] leading-relaxed">
<br /> .picobot/skills/ SKILL.md
</p>
</div> </div>
</div> </div>
)} )}
@ -120,23 +183,30 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
{/* list */} {/* list */}
{skills.length > 0 && ( {skills.length > 0 && (
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3"> <div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
{sorted.map(source => { {sorted.map((source) => {
const cfg = sourceConfig(source) const cfg = sourceConfig(source);
const items = grouped.get(source)! const items = grouped.get(source)!;
const closed = collapsed.has(source) const closed = collapsed.has(source);
return ( return (
<div key={source}> <div key={source}>
<SectionHeader config={cfg} count={items.length} isCollapsed={closed} onClick={() => toggle(source)} /> <SectionHeader
config={cfg}
count={items.length}
isCollapsed={closed}
onClick={() => toggle(source)}
/>
{!closed && ( {!closed && (
<div className="mt-1.5 space-y-1.5"> <div className="mt-1.5 space-y-1.5">
{items.map(s => <SkillCard key={s.name} skill={s} config={cfg} />)} {items.map((s) => (
<SkillCard key={s.name} skill={s} config={cfg} />
))}
</div> </div>
)} )}
</div> </div>
) );
})} })}
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@ -1,39 +1,43 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react';
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react' import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react';
import type { TodoItemSummary, Command } from '../../types/protocol' import type { TodoItemSummary, Command } from '../../types/protocol';
interface TodoPanelProps { interface TodoPanelProps {
todos: TodoItemSummary[] todos: TodoItemSummary[];
requestTodoList: () => Command requestTodoList: () => Command;
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => void;
onTodoClick?: (todo: TodoItemSummary) => void onTodoClick?: (todo: TodoItemSummary) => void;
} }
/* ── status config ────────────────────────────────────── */ /* ── status config ────────────────────────────────────── */
interface StatusCfg { label: string; color: string; dot: string } interface StatusCfg {
label: string;
color: string;
dot: string;
}
const STATUS: Record<string, StatusCfg> = { const STATUS: Record<string, StatusCfg> = {
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' }, in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' }, pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' }, completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' }, cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
} };
function statusCfg(s: string): StatusCfg { function statusCfg(s: string): StatusCfg {
return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' } return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' };
} }
const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled'] const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled'];
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> { function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
const map = new Map<string, TodoItemSummary[]>() const map = new Map<string, TodoItemSummary[]>();
for (const t of todos) { for (const t of todos) {
const list = map.get(t.status) ?? [] const list = map.get(t.status) ?? [];
list.push(t) list.push(t);
map.set(t.status, list) map.set(t.status, list);
} }
return map return map;
} }
/* ── pulse dot ────────────────────────────────────────── */ /* ── pulse dot ────────────────────────────────────────── */
@ -44,55 +48,67 @@ function PulseDot() {
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" /> <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
</span> </span>
) );
} }
/* ── TodoPanel ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) { export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled'])) const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
const prevTodoIdsRef = useRef<Set<string>>(new Set()) () => new Set(['completed', 'cancelled']),
);
const prevTodoIdsRef = useRef<Set<string>>(new Set());
// 新增待办时自动展开"进行中"分组 // 新增待办时自动展开"进行中"分组
useEffect(() => { useEffect(() => {
const newIds = new Set(todos.map(t => t.id)) const newIds = new Set(todos.map((t) => t.id));
if (todos.length > 0) { if (todos.length > 0) {
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id)) const hasNewItems = todos.some((t) => !prevTodoIdsRef.current.has(t.id));
if (hasNewItems) { if (hasNewItems) {
setCollapsedGroups(prev => { setCollapsedGroups((prev) => {
const next = new Set(prev) const next = new Set(prev);
next.delete('in_progress') next.delete('in_progress');
return next return next;
}) });
} }
} }
prevTodoIdsRef.current = newIds prevTodoIdsRef.current = newIds;
}, [todos]) }, [todos]);
const grouped = groupTodos(todos) const grouped = groupTodos(todos);
const inProgressCount = grouped.get('in_progress')?.length ?? 0 const inProgressCount = grouped.get('in_progress')?.length ?? 0;
const totalCount = todos.length const totalCount = todos.length;
const toggleGroup = useCallback((status: string) => { const toggleGroup = useCallback((status: string) => {
setCollapsedGroups(prev => { setCollapsedGroups((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(status)) next.delete(status); else next.add(status) if (next.has(status)) next.delete(status);
return next else next.add(status);
}) return next;
}, []) });
}, []);
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList]) const handleRefresh = useCallback(
() => sendCommand(requestTodoList()),
[sendCommand, requestTodoList],
);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* title bar */} {/* title bar */}
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50"> <div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" /> <ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight"></span> <span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight">
</span>
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span> <span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
{inProgressCount > 0 && <PulseDot />} {inProgressCount > 0 && <PulseDot />}
<div className="ml-auto flex items-center gap-0.5"> <div className="ml-auto flex items-center gap-0.5">
<button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新"> <button
onClick={handleRefresh}
className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
title="刷新"
>
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
@ -115,12 +131,12 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
</div> </div>
)} )}
{GROUP_ORDER.map(status => { {GROUP_ORDER.map((status) => {
const items = grouped.get(status) const items = grouped.get(status);
if (!items || items.length === 0) return null if (!items || items.length === 0) return null;
const cfg = statusCfg(status) const cfg = statusCfg(status);
const isCollapsed = collapsedGroups.has(status) const isCollapsed = collapsedGroups.has(status);
return ( return (
<div key={status} className="mt-1 first:mt-0"> <div key={status} className="mt-1 first:mt-0">
@ -130,15 +146,21 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
> >
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} /> <span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
<span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span> <span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
<span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span> <span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>
{items.length}
</span>
<span className="ml-auto text-[var(--text-muted)]/50"> <span className="ml-auto text-[var(--text-muted)]/50">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} /> <ChevronDown
className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}
/>
</span> </span>
</button> </button>
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}> <div
className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}
>
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5"> <div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
{items.map(item => ( {items.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => onTodoClick?.(item)} onClick={() => onTodoClick?.(item)}
@ -153,9 +175,9 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
) );
} }

View File

@ -1,52 +1,60 @@
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal, Maximize2 } from 'lucide-react' import {
import { useState, useMemo } from 'react' ChevronDown,
import type { ChatMessage } from '../../types/protocol' ChevronRight,
import { ToolDetailModal } from '../Chat/ToolDetailModal' Play,
Check,
AlertTriangle,
Terminal,
Maximize2,
} from 'lucide-react';
import { useState, useMemo } from 'react';
import type { ChatMessage } from '../../types/protocol';
import { ToolDetailModal } from '../Chat/ToolDetailModal';
interface ToolPanelProps { interface ToolPanelProps {
messages: ChatMessage[] messages: ChatMessage[];
} }
interface ToolCallItem { interface ToolCallItem {
toolCallId: string toolCallId: string;
toolName: string toolName: string;
status: 'calling' | 'result' | 'pending' status: 'calling' | 'result' | 'pending';
arguments?: unknown arguments?: unknown;
resultContent: string resultContent: string;
callContent: string callContent: string;
durationMs?: number durationMs?: number;
} }
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
if (ms < 1000) { if (ms < 1000) {
return `${ms}ms` return `${ms}ms`;
} }
if (ms < 60000) { if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s` return `${(ms / 1000).toFixed(1)}s`;
} }
const minutes = Math.floor(ms / 60000) const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000) const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m ${seconds}s` return `${minutes}m ${seconds}s`;
} }
function formatResultText(content: string): string { function formatResultText(content: string): string {
if (!content) return '' if (!content) return '';
try { try {
const parsed = JSON.parse(content) const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2) return JSON.stringify(parsed, null, 2);
} catch { } catch {
return content return content;
} }
} }
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] { function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
const map = new Map<string, ToolCallItem>() const map = new Map<string, ToolCallItem>();
for (const m of messages) { for (const m of messages) {
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue;
const key = m.toolCallId || m.id const key = m.toolCallId || m.id;
let entry = map.get(key) let entry = map.get(key);
if (!entry) { if (!entry) {
entry = { entry = {
@ -56,43 +64,43 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
arguments: undefined, arguments: undefined,
resultContent: '', resultContent: '',
callContent: '', callContent: '',
} };
map.set(key, entry) map.set(key, entry);
} }
if (m.type === 'tool_call') { if (m.type === 'tool_call') {
entry.arguments = m.arguments entry.arguments = m.arguments;
entry.callContent = m.content entry.callContent = m.content;
} else if (m.type === 'tool_result') { } else if (m.type === 'tool_result') {
entry.status = 'result' entry.status = 'result';
entry.resultContent = m.content entry.resultContent = m.content;
entry.durationMs = m.durationMs entry.durationMs = m.durationMs;
} else if (m.type === 'tool_pending') { } else if (m.type === 'tool_pending') {
entry.status = 'pending' entry.status = 'pending';
entry.resultContent = m.content entry.resultContent = m.content;
} }
} }
return Array.from(map.values()) return Array.from(map.values());
} }
export function ToolPanel({ messages }: ToolPanelProps) { export function ToolPanel({ messages }: ToolPanelProps) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set()) const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null) const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null);
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages]) const toolCalls = useMemo(() => mergeToolMessages(messages), [messages]);
const toggleExpand = (id: string) => { const toggleExpand = (id: string) => {
setExpandedTools((prev) => { setExpandedTools((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(id)) { if (next.has(id)) {
next.delete(id) next.delete(id);
} else { } else {
next.add(id) next.add(id);
} }
return next return next;
}) });
} };
const getStatusConfig = (status: ToolCallItem['status']) => { const getStatusConfig = (status: ToolCallItem['status']) => {
switch (status) { switch (status) {
@ -104,7 +112,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
borderClass: 'border-amber-500/30', borderClass: 'border-amber-500/30',
label: '执行中', label: '执行中',
labelClass: 'text-amber-400', labelClass: 'text-amber-400',
} };
case 'result': case 'result':
return { return {
icon: Check, icon: Check,
@ -113,7 +121,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
borderClass: 'border-emerald-500/30', borderClass: 'border-emerald-500/30',
label: '已完成', label: '已完成',
labelClass: 'text-emerald-400', labelClass: 'text-emerald-400',
} };
case 'pending': case 'pending':
return { return {
icon: AlertTriangle, icon: AlertTriangle,
@ -122,9 +130,9 @@ export function ToolPanel({ messages }: ToolPanelProps) {
borderClass: 'border-orange-500/30', borderClass: 'border-orange-500/30',
label: '待确认', label: '待确认',
labelClass: 'text-orange-400', labelClass: 'text-orange-400',
} };
} }
} };
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
return ( return (
@ -141,123 +149,144 @@ export function ToolPanel({ messages }: ToolPanelProps) {
</div> </div>
</div> </div>
</div> </div>
) );
} }
return ( return (
<> <>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<style>{animStyles}</style> <style>{animStyles}</style>
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2"> <div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
<Terminal className="h-4 w-4 text-[var(--accent-cyan)]" /> <Terminal className="h-4 w-4 text-[var(--accent-cyan)]" />
<span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]"> <span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]">
{toolCalls.length} {toolCalls.length}
</span> </span>
</div> </div>
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2"> <div className="space-y-2">
{toolCalls.map((tool) => { {toolCalls.map((tool) => {
const config = getStatusConfig(tool.status) const config = getStatusConfig(tool.status);
const StatusIcon = config.icon const StatusIcon = config.icon;
const isExpanded = expandedTools.has(tool.toolCallId) const isExpanded = expandedTools.has(tool.toolCallId);
const hasResult = tool.resultContent.length > 0 const hasResult = tool.resultContent.length > 0;
const displayContent = tool.resultContent || tool.callContent const displayContent = tool.resultContent || tool.callContent;
const formattedContent = formatResultText(displayContent) const formattedContent = formatResultText(displayContent);
const previewLines = displayContent.split('\n').slice(0, 2).join('\n') const previewLines = displayContent.split('\n').slice(0, 2).join('\n');
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200 const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200;
return ( return (
<div <div
key={tool.toolCallId} key={tool.toolCallId}
className={`rounded-xl border bg-[var(--bg-tertiary)]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`} className={`rounded-xl border bg-[var(--bg-tertiary)]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
>
<button
onClick={() => toggleExpand(tool.toolCallId)}
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-[var(--overlay-hover)] transition-colors"
> >
<div className="flex items-center gap-2 min-w-0"> <button
<span className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}> onClick={() => toggleExpand(tool.toolCallId)}
<StatusIcon className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`} /> className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-[var(--overlay-hover)] transition-colors"
</span> >
<span className="font-medium text-[var(--text-secondary)] truncate">{tool.toolName}</span> <div className="flex items-center gap-2 min-w-0">
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}> <span
{config.label} className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}
</span> >
{tool.status === 'result' && tool.durationMs != null && ( <StatusIcon
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1"> className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`}
{formatDuration(tool.durationMs)} />
</span> </span>
)} <span className="font-medium text-[var(--text-secondary)] truncate">
</div> {tool.toolName}
<div className="flex items-center gap-1 ml-2 flex-shrink-0"> </span>
<button <span
onClick={(e) => { e.stopPropagation(); setDetailModalTool(tool) }} className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}
className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors" >
title="放大查看" {config.label}
> </span>
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" /> {tool.status === 'result' && tool.durationMs != null && (
</button> <span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
{isExpanded ? ( {formatDuration(tool.durationMs)}
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" /> </span>
) : (
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
)}
</div>
</button>
{/* 结果预览区 — 始终可见 */}
{hasResult && (
<div className="px-3 pb-2">
<div
className={`rounded-lg bg-[var(--overlay-dim-strong)] px-2.5 py-2 text-xs text-[var(--text-secondary)] font-mono cursor-pointer hover:bg-[var(--overlay-dim-heavy)] transition-colors ${
isExpanded ? '' : 'line-clamp-2'
}`}
onClick={() => toggleExpand(tool.toolCallId)}
>
{isExpanded ? (
<pre className="whitespace-pre-wrap break-all m-0">{formattedContent}</pre>
) : (
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
)} )}
</div> </div>
{!isExpanded && hasMore && ( <div className="flex items-center gap-1 ml-2 flex-shrink-0">
<div className="text-xs text-[var(--text-muted)] mt-1 px-1"> <button
({displayContent.split('\n').length} ) onClick={(e) => {
</div> e.stopPropagation();
)} setDetailModalTool(tool);
</div> }}
)} className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors"
title="放大查看"
>
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" />
</button>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
) : (
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
)}
</div>
</button>
{/* 展开区域:参数 */} {/* 结果预览区 — 始终可见 */}
{isExpanded && tool.arguments ? ( {hasResult && (
<div className="border-t border-[var(--border-color)] px-3 py-2 bg-[var(--overlay-dim)]"> <div className="px-3 pb-2">
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">:</div> <div
<pre className="rounded-lg bg-[var(--overlay-dim-heavy)] p-2 text-xs overflow-x-auto text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all"> className={`rounded-lg bg-[var(--overlay-dim-strong)] px-2.5 py-2 text-xs text-[var(--text-secondary)] font-mono cursor-pointer hover:bg-[var(--overlay-dim-heavy)] transition-colors ${
{JSON.stringify(tool.arguments, null, 2)} isExpanded ? '' : 'line-clamp-2'
</pre> }`}
</div> onClick={() => toggleExpand(tool.toolCallId)}
) : null} >
</div> {isExpanded ? (
) <pre className="whitespace-pre-wrap break-all m-0">
})} {formattedContent}
</pre>
) : (
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
)}
</div>
{!isExpanded && hasMore && (
<div className="text-xs text-[var(--text-muted)] mt-1 px-1">
({displayContent.split('\n').length} )
</div>
)}
</div>
)}
{/* 展开区域:参数 */}
{isExpanded && tool.arguments ? (
<div className="border-t border-[var(--border-color)] px-3 py-2 bg-[var(--overlay-dim)]">
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">:</div>
<pre className="rounded-lg bg-[var(--overlay-dim-heavy)] p-2 text-xs overflow-x-auto text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all">
{JSON.stringify(tool.arguments, null, 2)}
</pre>
</div>
) : null}
</div>
);
})}
</div>
</div> </div>
</div> </div>
</div> {detailModalTool && (
{detailModalTool && ( <ToolDetailModal
<ToolDetailModal toolName={detailModalTool.toolName}
toolName={detailModalTool.toolName} status={detailModalTool.status}
status={detailModalTool.status} statusLabel={
statusLabel={detailModalTool.status === 'calling' ? '执行中' : detailModalTool.status === 'result' ? '已完成' : detailModalTool.status === 'pending' ? '待确认' : detailModalTool.status} detailModalTool.status === 'calling'
arguments={detailModalTool.arguments} ? '执行中'
resultContent={detailModalTool.resultContent} : detailModalTool.status === 'result'
callContent={detailModalTool.callContent} ? '已完成'
durationMs={detailModalTool.durationMs} : detailModalTool.status === 'pending'
onClose={() => setDetailModalTool(null)} ? '待确认'
/> : detailModalTool.status
)} }
arguments={detailModalTool.arguments}
resultContent={detailModalTool.resultContent}
callContent={detailModalTool.callContent}
durationMs={detailModalTool.durationMs}
onClose={() => setDetailModalTool(null)}
/>
)}
</> </>
) );
} }
const animStyles = ` const animStyles = `
@ -284,4 +313,4 @@ const animStyles = `
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
` `;

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +1,77 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { X, Wifi, RotateCcw } from 'lucide-react' import { X, Wifi, RotateCcw } from 'lucide-react';
export interface GatewaySettings { export interface GatewaySettings {
host: string host: string;
port: number port: number;
} }
const DEFAULT_HOST = '127.0.0.1' const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 19876 const DEFAULT_PORT = 19876;
export function getGatewaySettings(): GatewaySettings { export function getGatewaySettings(): GatewaySettings {
try { try {
const host = localStorage.getItem('picobot-gateway-host') || DEFAULT_HOST const host = localStorage.getItem('picobot-gateway-host') || DEFAULT_HOST;
const portStr = localStorage.getItem('picobot-gateway-port') const portStr = localStorage.getItem('picobot-gateway-port');
const port = portStr ? parseInt(portStr, 10) : DEFAULT_PORT const port = portStr ? parseInt(portStr, 10) : DEFAULT_PORT;
return { host, port: isNaN(port) ? DEFAULT_PORT : port } return { host, port: isNaN(port) ? DEFAULT_PORT : port };
} catch { } catch {
return { host: DEFAULT_HOST, port: DEFAULT_PORT } return { host: DEFAULT_HOST, port: DEFAULT_PORT };
} }
} }
export function buildWsUrl(settings: GatewaySettings): string { export function buildWsUrl(settings: GatewaySettings): string {
return `ws://${settings.host}:${settings.port}/ws` return `ws://${settings.host}:${settings.port}/ws`;
} }
interface SettingsModalProps { interface SettingsModalProps {
onClose: () => void onClose: () => void;
onSave: (settings: GatewaySettings) => void onSave: (settings: GatewaySettings) => void;
} }
export function SettingsModal({ onClose, onSave }: SettingsModalProps) { export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
const [host, setHost] = useState(DEFAULT_HOST) const [host, setHost] = useState(DEFAULT_HOST);
const [port, setPort] = useState(String(DEFAULT_PORT)) const [port, setPort] = useState(String(DEFAULT_PORT));
const [error, setError] = useState('') const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
const settings = getGatewaySettings() const settings = getGatewaySettings();
setHost(settings.host) setHost(settings.host);
setPort(String(settings.port)) setPort(String(settings.port));
}, []) }, []);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose();
} };
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]) }, [onClose]);
const handleSave = () => { const handleSave = () => {
const trimmedHost = host.trim() const trimmedHost = host.trim();
const portNum = parseInt(port, 10) const portNum = parseInt(port, 10);
if (!trimmedHost) { if (!trimmedHost) {
setError('主机地址不能为空') setError('主机地址不能为空');
return return;
} }
if (isNaN(portNum) || portNum < 1 || portNum > 65535) { if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
setError('端口号必须在 1-65535 之间') setError('端口号必须在 1-65535 之间');
return return;
} }
setError('') setError('');
localStorage.setItem('picobot-gateway-host', trimmedHost) localStorage.setItem('picobot-gateway-host', trimmedHost);
localStorage.setItem('picobot-gateway-port', String(portNum)) localStorage.setItem('picobot-gateway-port', String(portNum));
onSave({ host: trimmedHost, port: portNum }) onSave({ host: trimmedHost, port: portNum });
} };
const handleReset = () => { const handleReset = () => {
setHost(DEFAULT_HOST) setHost(DEFAULT_HOST);
setPort(String(DEFAULT_PORT)) setPort(String(DEFAULT_PORT));
setError('') setError('');
} };
return ( return (
<div <div
@ -107,7 +107,10 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
<input <input
type="text" type="text"
value={host} value={host}
onChange={(e) => { setHost(e.target.value); setError('') }} onChange={(e) => {
setHost(e.target.value);
setError('');
}}
placeholder="127.0.0.1" placeholder="127.0.0.1"
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors" className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
/> />
@ -121,7 +124,10 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
<input <input
type="number" type="number"
value={port} value={port}
onChange={(e) => { setPort(e.target.value); setError('') }} onChange={(e) => {
setPort(e.target.value);
setError('');
}}
placeholder="19876" placeholder="19876"
min={1} min={1}
max={65535} max={65535}
@ -167,5 +173,5 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,9 +1,21 @@
// Config-related constants extracted from ConfigPage.tsx // Config-related constants extracted from ConfigPage.tsx
import { import {
Settings, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image, Settings,
Plug, Radio, Wifi, Server, Users, UserCheck, Cpu,
} from 'lucide-react' Bot,
import type { TabId } from './types' Clock,
Calendar,
Wrench,
Brain,
Image,
Plug,
Radio,
Wifi,
Server,
Users,
UserCheck,
} from 'lucide-react';
import type { TabId } from './types';
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'providers', label: '服务商', icon: Cpu }, { id: 'providers', label: '服务商', icon: Cpu },
@ -21,10 +33,11 @@ export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
{ id: 'time', label: '时间', icon: Clock }, { id: 'time', label: '时间', icon: Clock },
{ id: 'connection', label: '连接', icon: Wifi }, { id: 'connection', label: '连接', icon: Wifi },
{ id: 'gateway', label: '网关', icon: Server }, { id: 'gateway', label: '网关', icon: Server },
] ];
export const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors" export const inputCls =
export const selectCls = inputCls 'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
export const selectCls = inputCls;
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [ export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' }, { value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' },
@ -47,4 +60,4 @@ export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' }, { value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' },
{ value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' }, { value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
{ value: 'UTC', label: 'UTC (协调世界时)' }, { value: 'UTC', label: 'UTC (协调世界时)' },
] ];

View File

@ -1,200 +1,277 @@
// Config-related type definitions extracted from ConfigPage.tsx // Config-related type definitions extracted from ConfigPage.tsx
export interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; llm_timeout_secs: number; memory_maintenance_timeout_secs: number } export interface ProviderConfig {
export interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number } type: string;
export interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number } base_url: string;
export interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number } api_key: string;
export interface TimeConfig { timezone: string } extra_headers: Record<string, string>;
export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] } llm_timeout_secs: number;
export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } memory_maintenance_timeout_secs: number;
export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[]; max_nesting_depth: number } }
export interface ToolsConfig { disabled: string[]; task: TaskConfig } export interface ModelConfig {
export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } model_id: string;
export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } temperature?: number;
export interface SubagentsConfig { enabled: boolean; sources: string[] } max_tokens?: number;
export interface ClientConfig { gateway_url: string } context_window_tokens?: number;
}
export interface AgentConfig {
provider: string;
model: string;
max_tool_iterations: number;
tool_result_max_chars: number;
context_tool_result_trim_chars: number;
}
export interface GatewayConfig {
host: string;
port: number;
show_tool_results: boolean;
agent_prompt_reinject_every: number;
max_concurrent_requests: number;
session_ttl_hours?: number;
}
export interface TimeConfig {
timezone: string;
}
export interface SchedulerConfig {
enabled: boolean;
tick_resolution_ms: number;
worker_queue_capacity: number;
misfire_policy: 'skip' | 'catch_up';
jobs?: SchedulerJobConfig[];
}
export interface SkillsConfig {
enabled: boolean;
sources: string[];
max_index_chars: number;
max_listed_skills: number;
}
export interface TaskConfig {
enabled: boolean;
max_execution_secs: number;
ttl_hours: number;
allowed_tools: string[];
max_nesting_depth: number;
}
export interface ToolsConfig {
disabled: string[];
task: TaskConfig;
}
export interface MemoryMaintenanceConfig {
max_merge_ratio: number;
min_memories_to_keep: number;
max_merge_per_group: number;
}
export interface ImageContextConfig {
max_images_in_context: number;
max_image_age_rounds: number;
}
export interface SubagentsConfig {
enabled: boolean;
sources: string[];
}
export interface ClientConfig {
gateway_url: string;
}
export interface McpServerConfig { export interface McpServerConfig {
name?: string name?: string;
type: 'stdio' | 'streamableHttp' | 'http' type: 'stdio' | 'streamableHttp' | 'http';
is_active: boolean is_active: boolean;
command?: string command?: string;
args?: string[] args?: string[];
env?: Record<string, string> env?: Record<string, string>;
cwd?: string cwd?: string;
base_url?: string base_url?: string;
headers?: Record<string, string> headers?: Record<string, string>;
description?: string description?: string;
} }
export interface SkillItem { export interface SkillItem {
name: string name: string;
description: string description: string;
source: string source: string;
path: string path: string;
disabled_in_scopes: string[] disabled_in_scopes: string[];
} }
export interface SkillListResponse { export interface SkillListResponse {
skills_system_enabled: boolean skills_system_enabled: boolean;
total: number total: number;
skills: SkillItem[] skills: SkillItem[];
} }
export interface ToolItem { export interface ToolItem {
name: string name: string;
description: string description: string;
/** "builtin" 或 "mcp:{server_key}" */ /** "builtin" 或 "mcp:{server_key}" */
source: string source: string;
} }
export interface ToolsListResponse { export interface ToolsListResponse {
total: number total: number;
tools: ToolItem[] tools: ToolItem[];
} }
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */ /** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
export interface ModelOptionsResponse { export interface ModelOptionsResponse {
providers: string[] providers: string[];
models: string[] models: string[];
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */ /** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
current: { provider: string, model: string } current: { provider: string; model: string };
} }
export interface CapabilityPolicy { export interface CapabilityPolicy {
allowed_skills?: string[] allowed_skills?: string[];
denied_skills: string[] denied_skills: string[];
allowed_tools?: string[] allowed_tools?: string[];
denied_tools: string[] denied_tools: string[];
allowed_subagents?: string[] allowed_subagents?: string[];
denied_subagents: string[] denied_subagents: string[];
} }
export interface SubagentItem { export interface SubagentItem {
name: string name: string;
description: string description: string;
source: string source: string;
disabled_in_scopes: string[] disabled_in_scopes: string[];
capability?: CapabilityPolicy capability?: CapabilityPolicy;
provider?: string provider?: string;
model?: string model?: string;
} }
export interface SubagentListResponse { export interface SubagentListResponse {
subagents_system_enabled: boolean subagents_system_enabled: boolean;
total: number total: number;
subagents: SubagentItem[] subagents: SubagentItem[];
} }
export interface ExpertsConfig { enabled: boolean; sources: string[] } export interface ExpertsConfig {
enabled: boolean;
sources: string[];
}
export interface ExpertItem { export interface ExpertItem {
name: string name: string;
description: string description: string;
source: string source: string;
path?: string path?: string;
body?: string body?: string;
disabled_in_scopes: string[] disabled_in_scopes: string[];
capability?: CapabilityPolicy capability?: CapabilityPolicy;
provider?: string provider?: string;
model?: string model?: string;
} }
export interface ExpertListResponse { export interface ExpertListResponse {
experts_system_enabled: boolean experts_system_enabled: boolean;
total: number total: number;
experts: ExpertItem[] experts: ExpertItem[];
} }
export interface McpServerStatus { export interface McpServerStatus {
key: string key: string;
name: string name: string;
transport_type: string transport_type: string;
is_active: boolean is_active: boolean;
connected: boolean connected: boolean;
tool_count: number tool_count: number;
error?: string error?: string;
} }
export interface McpStatusResponse { export interface McpStatusResponse {
enabled: boolean enabled: boolean;
total_servers: number total_servers: number;
connected_servers: number connected_servers: number;
failed_servers: number failed_servers: number;
total_tools: number total_tools: number;
servers: McpServerStatus[] servers: McpServerStatus[];
} }
export interface FeishuChannelConfig { export interface FeishuChannelConfig {
enabled: boolean enabled: boolean;
app_id: string app_id: string;
app_secret: string app_secret: string;
allow_from?: string[] allow_from?: string[];
agent?: string agent?: string;
media_dir?: string media_dir?: string;
reaction_emoji?: string reaction_emoji?: string;
max_message_chars?: number max_message_chars?: number;
reply_context_max_chars?: number reply_context_max_chars?: number;
} }
export interface WechatChannelConfig { export interface WechatChannelConfig {
enabled: boolean enabled: boolean;
base_url: string base_url: string;
cred_path: string cred_path: string;
force_login?: boolean force_login?: boolean;
allow_from?: string[] allow_from?: string[];
agent?: string agent?: string;
} }
export interface ChannelConfig { export interface ChannelConfig {
type?: string type?: string;
enabled?: boolean enabled?: boolean;
app_id?: string app_id?: string;
app_secret?: string app_secret?: string;
agent?: string agent?: string;
base_url?: string base_url?: string;
cred_path?: string cred_path?: string;
force_login?: boolean force_login?: boolean;
allow_from?: string[] allow_from?: string[];
media_dir?: string media_dir?: string;
reaction_emoji?: string reaction_emoji?: string;
max_message_chars?: number max_message_chars?: number;
reply_context_max_chars?: number reply_context_max_chars?: number;
[key: string]: unknown [key: string]: unknown;
} }
export interface SchedulerJobConfig { export interface SchedulerJobConfig {
id: string id: string;
enabled: boolean enabled: boolean;
kind: string kind: string;
[key: string]: unknown [key: string]: unknown;
} }
export interface AppConfig { export interface AppConfig {
providers: Record<string, ProviderConfig> providers: Record<string, ProviderConfig>;
models: Record<string, ModelConfig> models: Record<string, ModelConfig>;
agents: Record<string, AgentConfig> agents: Record<string, AgentConfig>;
time: TimeConfig time: TimeConfig;
gateway: GatewayConfig gateway: GatewayConfig;
scheduler: SchedulerConfig scheduler: SchedulerConfig;
skills: SkillsConfig skills: SkillsConfig;
tools: ToolsConfig tools: ToolsConfig;
memory_maintenance: MemoryMaintenanceConfig memory_maintenance: MemoryMaintenanceConfig;
image_context: ImageContextConfig image_context: ImageContextConfig;
subagents: SubagentsConfig subagents: SubagentsConfig;
experts: ExpertsConfig experts: ExpertsConfig;
client: ClientConfig client: ClientConfig;
channels: Record<string, ChannelConfig> channels: Record<string, ChannelConfig>;
mcpServers: Record<string, McpServerConfig> mcpServers: Record<string, McpServerConfig>;
} }
export type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels' export type TabId =
| 'connection'
| 'gateway'
| 'providers'
| 'models'
| 'agents'
| 'time'
| 'scheduler'
| 'skills'
| 'tools'
| 'memory'
| 'image'
| 'subagents'
| 'experts'
| 'mcp'
| 'channels';
export interface ConfigPageProps { export interface ConfigPageProps {
onClose: () => void onClose: () => void;
onSaveConnection?: (host: string, port: number) => void onSaveConnection?: (host: string, port: number) => void;
initialTab?: TabId initialTab?: TabId;
} }
export interface KnownSource { export interface KnownSource {
key: string key: string;
label: string label: string;
description: string description: string;
} }

View File

@ -1,70 +1,129 @@
// Shared UI primitives extracted from ConfigPage.tsx // Shared UI primitives extracted from ConfigPage.tsx
import { useState, type ReactNode } from 'react' import { useState, type ReactNode } from 'react';
import { X, Plus, Trash2 } from 'lucide-react' import { X, Plus, Trash2 } from 'lucide-react';
import { inputCls } from './constants' import { inputCls } from './constants';
import type { KnownSource } from './types' import type { KnownSource } from './types';
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) { export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label> <label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children} {children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>} {hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div> </div>
) );
} }
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return ( return (
<button <button
type="button" type="button"
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`} className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
> >
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} /> <span
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`}
/>
</button> </button>
) );
} }
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) { export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('') const [input, setInput] = useState('');
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } } const add = () => {
const v = input.trim();
if (v && !tags.includes(v)) {
onChange([...tags, v]);
setInput('');
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{tags.map((t, i) => ( {tags.map((t, i) => (
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]"> <span
key={t}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]"
>
{t} {t}
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button> <button
onClick={() => onChange(tags.filter((_, j) => j !== i))}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span> </span>
))} ))}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} /> <input
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs"> value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
placeholder="输入后按 Enter"
className={inputCls + ' !text-xs'}
/>
<button
onClick={add}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs"
>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
</div> </div>
) );
} }
export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { export function SectionCard({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return ( return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"> <div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30"> <div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3> <h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>} {subtitle && (
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">
{subtitle}
</span>
)}
</div> </div>
</div> </div>
<div className="p-4 space-y-4">{children}</div> <div className="p-4 space-y-4">{children}</div>
</div> </div>
) );
} }
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */ /** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title: string; onClose: () => void }) { export function ModalHeader({
icon,
title,
onClose,
}: {
icon: ReactNode;
title: string;
onClose: () => void;
}) {
return ( return (
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50"> <div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
{icon} {icon}
@ -78,7 +137,7 @@ export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title:
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
) );
} }
/** 模态框底部按钮区,与项目模态框惯例一致 */ /** 模态框底部按钮区,与项目模态框惯例一致 */
@ -87,7 +146,7 @@ export function ModalFooter({ children }: { children: ReactNode }) {
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end"> <div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
{children} {children}
</div> </div>
) );
} }
export function SourceEditor({ export function SourceEditor({
@ -97,41 +156,41 @@ export function SourceEditor({
examplePaths, examplePaths,
showCustom = true, showCustom = true,
}: { }: {
sources: string[] sources: string[];
onChange: (s: string[]) => void onChange: (s: string[]) => void;
knownSources: KnownSource[] knownSources: KnownSource[];
examplePaths?: string[] examplePaths?: string[];
showCustom?: boolean showCustom?: boolean;
}) { }) {
const [customInput, setCustomInput] = useState('') const [customInput, setCustomInput] = useState('');
const knownKeys = new Set(knownSources.map(k => k.key)) const knownKeys = new Set(knownSources.map((k) => k.key));
const customPaths = sources.filter(s => !knownKeys.has(s)) const customPaths = sources.filter((s) => !knownKeys.has(s));
const toggleKnown = (key: string) => { const toggleKnown = (key: string) => {
if (sources.includes(key)) { if (sources.includes(key)) {
onChange(sources.filter(s => s !== key)) onChange(sources.filter((s) => s !== key));
} else { } else {
onChange([...sources, key]) onChange([...sources, key]);
} }
} };
const addCustom = () => { const addCustom = () => {
const v = customInput.trim() const v = customInput.trim();
if (v && !sources.includes(v)) { if (v && !sources.includes(v)) {
onChange([...sources, v]) onChange([...sources, v]);
setCustomInput('') setCustomInput('');
} }
} };
const removeCustom = (path: string) => { const removeCustom = (path: string) => {
onChange(sources.filter(s => s !== path)) onChange(sources.filter((s) => s !== path));
} };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Known sources as toggles */} {/* Known sources as toggles */}
<div className="space-y-2"> <div className="space-y-2">
{knownSources.map(src => ( {knownSources.map((src) => (
<div key={src.key} className="flex items-center justify-between py-1.5"> <div key={src.key} className="flex items-center justify-between py-1.5">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)]">{src.label}</div> <div className="text-sm text-[var(--text-primary)]">{src.label}</div>
@ -145,13 +204,23 @@ export function SourceEditor({
{/* Custom paths (only shown when showCustom is true) */} {/* Custom paths (only shown when showCustom is true) */}
{showCustom && ( {showCustom && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider"></div> <div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
</div>
{customPaths.length > 0 && ( {customPaths.length > 0 && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{customPaths.map((p) => ( {customPaths.map((p) => (
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"> <span
key={p}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{p} {p}
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button> <button
onClick={() => removeCustom(p)}
className="hover:text-white transition-colors"
>
<X className="h-3 w-3" />
</button>
</span> </span>
))} ))}
</div> </div>
@ -159,95 +228,107 @@ export function SourceEditor({
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
value={customInput} value={customInput}
onChange={e => setCustomInput(e.target.value)} onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills" placeholder="输入绝对路径,如 D:\my-skills"
className={inputCls + ' !text-xs font-mono'} className={inputCls + ' !text-xs font-mono'}
/> />
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0"> <button
onClick={addCustom}
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0"
>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
{examplePaths && ( {examplePaths && (
<p className="text-xs text-[var(--text-muted)]"> <p className="text-xs text-[var(--text-muted)]">: {examplePaths.join('、')}</p>
: {examplePaths.join('、')}
</p>
)} )}
</div> </div>
)} )}
</div> </div>
) );
} }
export interface CheckboxListOption { export interface CheckboxListOption {
key: string key: string;
label: string label: string;
description?: string description?: string;
/** 可选分组标识,配合 groupBy 使用 */ /** 可选分组标识,配合 groupBy 使用 */
group?: string group?: string;
} }
interface CheckboxListProps { interface CheckboxListProps {
options: CheckboxListOption[] options: CheckboxListOption[];
selected: string[] selected: string[];
onChange: (selected: string[]) => void onChange: (selected: string[]) => void;
/** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */ /** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */
extraSelected?: string[] extraSelected?: string[];
emptyHint?: string emptyHint?: string;
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */ /** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */
groupBy?: (option: CheckboxListOption) => string groupBy?: (option: CheckboxListOption) => string;
} }
/** /**
* Toggle legacy * Toggle legacy
*/ */
export function CheckboxList({ options, selected, onChange, extraSelected = [], emptyHint, groupBy }: CheckboxListProps) { export function CheckboxList({
options,
selected,
onChange,
extraSelected = [],
emptyHint,
groupBy,
}: CheckboxListProps) {
const toggle = (key: string) => const toggle = (key: string) =>
onChange(selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key]) onChange(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
const selectedSet = new Set(selected) const selectedSet = new Set(selected);
const optionsInList = new Set(options.map(o => o.key)) const optionsInList = new Set(options.map((o) => o.key));
// 仅展示未出现在 options 中的额外已选值 // 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter(k => !optionsInList.has(k)) const extra = extraSelected.filter((k) => !optionsInList.has(k));
// 分组渲染 // 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => ( const renderOptions = (opts: CheckboxListOption[]) => (
<div className="space-y-1"> <div className="space-y-1">
{opts.map(option => ( {opts.map((option) => (
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3"> <div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div> <div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
{option.description && <div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>} {option.description && (
<div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>
)}
</div> </div>
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} /> <Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
</div> </div>
))} ))}
</div> </div>
) );
let body: ReactNode let body: ReactNode;
if (options.length === 0) { if (options.length === 0) {
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p> body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>;
} else if (groupBy) { } else if (groupBy) {
const groups = new Map<string, CheckboxListOption[]>() const groups = new Map<string, CheckboxListOption[]>();
for (const opt of options) { for (const opt of options) {
const g = groupBy(opt) const g = groupBy(opt);
const arr = groups.get(g) ?? [] const arr = groups.get(g) ?? [];
arr.push(opt) arr.push(opt);
groups.set(g, arr) groups.set(g, arr);
} }
body = ( body = (
<div className="space-y-3"> <div className="space-y-3">
{Array.from(groups.entries()).map(([g, opts]) => ( {Array.from(groups.entries()).map(([g, opts]) => (
<div key={g}> <div key={g}>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">{g}</div> <div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
{g}
</div>
{renderOptions(opts)} {renderOptions(opts)}
</div> </div>
))} ))}
</div> </div>
) );
} else { } else {
body = renderOptions(options) body = renderOptions(options);
} }
return ( return (
@ -255,33 +336,70 @@ export function CheckboxList({ options, selected, onChange, extraSelected = [],
{body} {body}
{extra.length > 0 && ( {extra.length > 0 && (
<div> <div>
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1"></div> <div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{extra.map(key => ( {extra.map((key) => (
<span key={key} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"> <span
key={key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
>
{key} {key}
<button onClick={() => toggle(key)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button> <button onClick={() => toggle(key)} className="hover:text-white transition-colors">
<X className="h-3 w-3" />
</button>
</span> </span>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
) );
} }
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) { export function MapEntryHeader({
const [editing, setEditing] = useState(false) name,
const [val, setVal] = useState(name) onDelete,
onRename,
}: {
name: string;
onDelete: () => void;
onRename?: (n: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
return ( return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]"> <div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{editing ? ( {editing ? (
<input value={val} onChange={e => setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus /> <input
value={val}
onChange={(e) => setVal(e.target.value)}
onBlur={() => {
setEditing(false);
onRename?.(val.trim() || name);
}}
onKeyDown={(e) =>
e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))
}
className={inputCls + ' !py-1 !text-xs max-w-[200px]'}
autoFocus
/>
) : ( ) : (
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span> <span
className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer"
onClick={() => onRename && setEditing(true)}
>
{name}
</span>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button> <button
onClick={onDelete}
className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div> </div>
) );
} }

View File

@ -1,11 +1,11 @@
import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react' import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react';
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol' import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol';
interface SchedulerJobListProps { interface SchedulerJobListProps {
jobs: SchedulerJobSummary[] jobs: SchedulerJobSummary[];
onRefresh: () => void onRefresh: () => void;
onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void;
sessionId: string | null sessionId: string | null;
} }
function kindLabel(kind: string): string { function kindLabel(kind: string): string {
@ -14,61 +14,81 @@ function kindLabel(kind: string): string {
outbound_message: '外发消息', outbound_message: '外发消息',
agent_task: '智能体', agent_task: '智能体',
silent_agent_task: '静默智能体', silent_agent_task: '静默智能体',
} };
return map[kind] ?? kind return map[kind] ?? kind;
} }
function stateBadge(state: string): { label: string; color: string; pulse: boolean } { function stateBadge(state: string): { label: string; color: string; pulse: boolean } {
switch (state) { switch (state) {
case 'running': case 'running':
return { label: '执行中', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', pulse: true } return {
label: '执行中',
color: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
pulse: true,
};
case 'scheduled': case 'scheduled':
return { label: '已调度', color: 'bg-amber-500/15 text-amber-300 border-amber-500/20', pulse: false } return {
label: '已调度',
color: 'bg-amber-500/15 text-amber-300 border-amber-500/20',
pulse: false,
};
case 'paused': case 'paused':
return { label: '已暂停', color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false } return {
label: '已暂停',
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
pulse: false,
};
case 'completed': case 'completed':
return { label: '已完成', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', pulse: false } return {
label: '已完成',
color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
pulse: false,
};
default: default:
return { label: state, color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false } return {
label: state,
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
pulse: false,
};
} }
} }
function formatTime(tsMillis: number | undefined): string { function formatTime(tsMillis: number | undefined): string {
if (tsMillis == null) return '--' if (tsMillis == null) return '--';
const d = new Date(tsMillis) const d = new Date(tsMillis);
return d.toLocaleString('zh-CN', { return d.toLocaleString('zh-CN', {
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) });
} }
function scheduleDescription(schedule: unknown): string { function scheduleDescription(schedule: unknown): string {
if (!schedule || typeof schedule !== 'object') return '--' if (!schedule || typeof schedule !== 'object') return '--';
const s = schedule as Record<string, unknown> const s = schedule as Record<string, unknown>;
switch (s.type) { switch (s.type) {
case 'cron': case 'cron':
return `Cron: ${s.expression}` return `Cron: ${s.expression}`;
case 'interval': case 'interval':
return `${s.seconds}` return `${s.seconds}`;
case 'delay': case 'delay':
return `延迟 ${s.seconds}` return `延迟 ${s.seconds}`;
case 'at': case 'at':
return `定时: ${s.timestamp}` return `定时: ${s.timestamp}`;
default: default:
return JSON.stringify(s) return JSON.stringify(s);
} }
} }
function lastStatusIcon(lastStatus: string | undefined) { function lastStatusIcon(lastStatus: string | undefined) {
switch (lastStatus) { switch (lastStatus) {
case 'ok': case 'ok':
return <Check className="h-3 w-3 text-emerald-400" /> return <Check className="h-3 w-3 text-emerald-400" />;
case 'error': case 'error':
return <X className="h-3 w-3 text-red-400" /> return <X className="h-3 w-3 text-red-400" />;
default: default:
return <Minus className="h-3 w-3 text-[var(--text-muted)]" /> return <Minus className="h-3 w-3 text-[var(--text-muted)]" />;
} }
} }
@ -108,14 +128,14 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
) : ( ) : (
<div className="space-y-1.5"> <div className="space-y-1.5">
{jobs.map((job) => { {jobs.map((job) => {
const badge = stateBadge(job.state) const badge = stateBadge(job.state);
const hasLookup = !!job.session_lookup const hasLookup = !!job.session_lookup;
return ( return (
<button <button
key={job.id} key={job.id}
onClick={() => { onClick={() => {
if (hasLookup && job.session_lookup) { if (hasLookup && job.session_lookup) {
onViewJob(job.session_lookup, job.id, job.id) onViewJob(job.session_lookup, job.id, job.id);
} }
}} }}
disabled={!hasLookup} disabled={!hasLookup}
@ -136,26 +156,43 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`} /> <span
{hasLookup && <ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />} className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`}
/>
{hasLookup && (
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
)}
</div> </div>
</div> </div>
{/* Row 2: State badge + last status */} {/* Row 2: State badge + last status */}
<div className="flex items-center gap-2 mb-1.5"> <div className="flex items-center gap-2 mb-1.5">
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}> <span
{badge.pulse && <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />} className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}
>
{badge.pulse && (
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
)}
{badge.label} {badge.label}
</span> </span>
<span className="inline-flex items-center gap-0.5 text-xs text-[var(--text-muted)]"> <span className="inline-flex items-center gap-0.5 text-xs text-[var(--text-muted)]">
{lastStatusIcon(job.last_status)} {lastStatusIcon(job.last_status)}
<span className={ <span
job.last_status === 'error' ? 'text-red-400' : className={
job.last_status === 'ok' ? 'text-emerald-400' : 'text-[var(--text-muted)]' job.last_status === 'error'
}> ? 'text-red-400'
{job.last_status === 'ok' ? '正常' : : job.last_status === 'ok'
job.last_status === 'error' ? '异常' : ? 'text-emerald-400'
job.last_status === 'skipped' ? '跳过' : '--'} : 'text-[var(--text-muted)]'
}
>
{job.last_status === 'ok'
? '正常'
: job.last_status === 'error'
? '异常'
: job.last_status === 'skipped'
? '跳过'
: '--'}
</span> </span>
</span> </span>
</div> </div>
@ -170,7 +207,8 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
{/* Row 4: Run count + schedule */} {/* Row 4: Run count + schedule */}
<div className="flex items-center justify-between text-xs text-[var(--text-muted)] mb-1"> <div className="flex items-center justify-between text-xs text-[var(--text-muted)] mb-1">
<span> <span>
: {job.run_count}{job.max_runs ? `/${job.max_runs}` : ''} : {job.run_count}
{job.max_runs ? `/${job.max_runs}` : ''}
</span> </span>
<span className="truncate max-w-[120px] text-[var(--text-muted)]"> <span className="truncate max-w-[120px] text-[var(--text-muted)]">
{scheduleDescription(job.schedule)} {scheduleDescription(job.schedule)}
@ -183,11 +221,11 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
<span>: {formatTime(job.next_fire_at)}</span> <span>: {formatTime(job.next_fire_at)}</span>
</div> </div>
</button> </button>
) );
})} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@ -1,9 +1,9 @@
import { Wifi, FolderOpen, Hash } from 'lucide-react' import { Wifi, FolderOpen, Hash } from 'lucide-react';
import type { Session } from '../../types/protocol' import type { Session } from '../../types/protocol';
interface SessionInfoProps { interface SessionInfoProps {
session: Session | null session: Session | null;
connectionId: string | null connectionId: string | null;
} }
export function SessionInfo({ session, connectionId }: SessionInfoProps) { export function SessionInfo({ session, connectionId }: SessionInfoProps) {
@ -20,7 +20,9 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/80 p-3"> <div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/80 p-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<FolderOpen className="h-4 w-4 text-[var(--text-secondary)]" /> <FolderOpen className="h-4 w-4 text-[var(--text-secondary)]" />
<span className="text-xs text-[var(--text-muted)] uppercase tracking-wider"></span> <span className="text-xs text-[var(--text-muted)] uppercase tracking-wider">
</span>
</div> </div>
{session ? ( {session ? (
@ -46,5 +48,5 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@ -1,32 +1,45 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react' import {
import type { Topic } from '../../types/protocol' Plus,
MessageSquare,
Layers,
Hash,
Clock,
RefreshCw,
Trash2,
Check,
X,
ChevronLeft,
ChevronRight,
Edit2,
} from 'lucide-react';
import type { Topic } from '../../types/protocol';
interface TopicListProps { interface TopicListProps {
sessionId: string | null sessionId: string | null;
topics: Topic[] topics: Topic[];
currentTopicId: string | null currentTopicId: string | null;
isReadOnly: boolean isReadOnly: boolean;
onCreateTopic: () => void onCreateTopic: () => void;
onRefresh: () => void onRefresh: () => void;
onSwitchTopic: (topicId: string) => void onSwitchTopic: (topicId: string) => void;
onDeleteTopic: (topicId: string) => void onDeleteTopic: (topicId: string) => void;
onRenameTopic: (topicId: string, title: string) => void onRenameTopic: (topicId: string, title: string) => void;
} }
function formatTime(timestamp: number): string { function formatTime(timestamp: number): string {
const date = new Date(timestamp) const date = new Date(timestamp);
const now = new Date() const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)) const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) { if (diffDays === 0) {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) { } else if (diffDays === 1) {
return '昨天' return '昨天';
} else if (diffDays < 7) { } else if (diffDays < 7) {
return `${diffDays}天前` return `${diffDays}天前`;
} else { } else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
} }
} }
@ -41,79 +54,79 @@ export function TopicList({
onDeleteTopic, onDeleteTopic,
onRenameTopic, onRenameTopic,
}: TopicListProps) { }: TopicListProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null) const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [editingTopicId, setEditingTopicId] = useState<string | null>(null) const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('') const [editingTitle, setEditingTitle] = useState('');
const editInputRef = useRef<HTMLInputElement>(null) const editInputRef = useRef<HTMLInputElement>(null);
// 进入编辑模式时自动聚焦 input // 进入编辑模式时自动聚焦 input
useEffect(() => { useEffect(() => {
if (editingTopicId && editInputRef.current) { if (editingTopicId && editInputRef.current) {
editInputRef.current.focus() editInputRef.current.focus();
editInputRef.current.select() editInputRef.current.select();
} }
}, [editingTopicId]) }, [editingTopicId]);
const startEdit = useCallback((topic: Topic) => { const startEdit = useCallback((topic: Topic) => {
setConfirmDeleteId(null) setConfirmDeleteId(null);
setEditingTopicId(topic.id) setEditingTopicId(topic.id);
setEditingTitle(topic.title) setEditingTitle(topic.title);
}, []) }, []);
const cancelEdit = useCallback(() => { const cancelEdit = useCallback(() => {
setEditingTopicId(null) setEditingTopicId(null);
setEditingTitle('') setEditingTitle('');
}, []) }, []);
const commitEdit = useCallback(() => { const commitEdit = useCallback(() => {
const trimmed = editingTitle.trim() const trimmed = editingTitle.trim();
if (!trimmed || !editingTopicId) { if (!trimmed || !editingTopicId) {
cancelEdit() cancelEdit();
return return;
} }
onRenameTopic(editingTopicId, trimmed) onRenameTopic(editingTopicId, trimmed);
setEditingTopicId(null) setEditingTopicId(null);
setEditingTitle('') setEditingTitle('');
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]) }, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]);
// Pagination — dynamically sized to fill one screen without scrolling // Pagination — dynamically sized to fill one screen without scrolling
const ESTIMATED_ITEM_HEIGHT = 64 // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px) const ESTIMATED_ITEM_HEIGHT = 64; // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
const LIST_PADDING = 24 // p-3 top + bottom const LIST_PADDING = 24; // p-3 top + bottom
const [pageSize, setPageSize] = useState(8) // fallback before measurement const [pageSize, setPageSize] = useState(8); // fallback before measurement
const [currentPage, setCurrentPage] = useState(0) const [currentPage, setCurrentPage] = useState(0);
const listRef = useRef<HTMLDivElement>(null) const listRef = useRef<HTMLDivElement>(null);
const measurePageSize = useCallback(() => { const measurePageSize = useCallback(() => {
const el = listRef.current const el = listRef.current;
if (!el) return if (!el) return;
const available = el.clientHeight - LIST_PADDING const available = el.clientHeight - LIST_PADDING;
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1)) setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1));
}, []) }, []);
useEffect(() => { useEffect(() => {
measurePageSize() measurePageSize();
const el = listRef.current const el = listRef.current;
if (!el) return if (!el) return;
const observer = new ResizeObserver(() => measurePageSize()) const observer = new ResizeObserver(() => measurePageSize());
observer.observe(el) observer.observe(el);
return () => observer.disconnect() return () => observer.disconnect();
}, [measurePageSize]) }, [measurePageSize]);
const totalPages = useMemo( const totalPages = useMemo(
() => Math.max(1, Math.ceil(topics.length / pageSize)), () => Math.max(1, Math.ceil(topics.length / pageSize)),
[topics.length, pageSize] [topics.length, pageSize],
) );
const pagedTopics = useMemo( const pagedTopics = useMemo(
() => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize), () => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize),
[topics, currentPage, pageSize] [topics, currentPage, pageSize],
) );
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page) // Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
useEffect(() => { useEffect(() => {
if (currentPage >= totalPages) { if (currentPage >= totalPages) {
setCurrentPage(Math.max(0, totalPages - 1)) setCurrentPage(Math.max(0, totalPages - 1));
} }
}, [currentPage, totalPages]) }, [currentPage, totalPages]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
@ -178,8 +191,8 @@ export function TopicList({
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发 // 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault();
commitEdit() commitEdit();
}} }}
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40" className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
> >
@ -189,8 +202,8 @@ export function TopicList({
onChange={(e) => setEditingTitle(e.target.value)} onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault();
cancelEdit() cancelEdit();
} }
}} }}
onBlur={cancelEdit} onBlur={cancelEdit}
@ -231,9 +244,13 @@ export function TopicList({
{currentPage * pageSize + index + 1} {currentPage * pageSize + index + 1}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className={`truncate font-medium ${ <div
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]' className={`truncate font-medium ${
}`}> topic.id === currentTopicId
? 'text-[var(--accent-cyan)]'
: 'text-[var(--text-secondary)]'
}`}
>
{topic.description || topic.title} {topic.description || topic.title}
</div> </div>
<div className="flex items-center gap-3 mt-1.5"> <div className="flex items-center gap-3 mt-1.5">
@ -260,9 +277,9 @@ export function TopicList({
<span className="text-xs text-red-400 whitespace-nowrap">?</span> <span className="text-xs text-red-400 whitespace-nowrap">?</span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
onDeleteTopic(topic.id) onDeleteTopic(topic.id);
setConfirmDeleteId(null) setConfirmDeleteId(null);
}} }}
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors" className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
title="确认" title="确认"
@ -271,8 +288,8 @@ export function TopicList({
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setConfirmDeleteId(null) setConfirmDeleteId(null);
}} }}
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors" className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
title="取消" title="取消"
@ -284,8 +301,8 @@ export function TopicList({
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
startEdit(topic) startEdit(topic);
}} }}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors" className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
title="重命名话题" title="重命名话题"
@ -294,8 +311,8 @@ export function TopicList({
</button> </button>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setConfirmDeleteId(topic.id) setConfirmDeleteId(topic.id);
}} }}
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors" className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="删除话题" title="删除话题"
@ -317,7 +334,7 @@ export function TopicList({
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2"> <div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
<button <button
onClick={() => setCurrentPage(p => Math.max(0, p - 1))} onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0} disabled={currentPage === 0}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all" className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
> >
@ -327,7 +344,7 @@ export function TopicList({
{currentPage + 1} / {totalPages} {currentPage + 1} / {totalPages}
</span> </span>
<button <button
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))} onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage >= totalPages - 1} disabled={currentPage >= totalPages - 1}
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all" className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
> >
@ -336,5 +353,5 @@ export function TopicList({
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@ -9,42 +9,46 @@ import type {
StreamEnd, StreamEnd,
ExecutionCompleted, ExecutionCompleted,
WsError, WsError,
} from '../../types/protocol' } from '../../types/protocol';
// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref // 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref
let messageIdCounter = 0 let messageIdCounter = 0;
export function generateMessageId(): string { export function generateMessageId(): string {
messageIdCounter += 1 messageIdCounter += 1;
return `msg_${Date.now()}_${messageIdCounter}` return `msg_${Date.now()}_${messageIdCounter}`;
} }
/** 重置计数器(仅测试使用) */ /** 重置计数器(仅测试使用) */
export function _resetMessageIdCounterForTests(): void { export function _resetMessageIdCounterForTests(): void {
messageIdCounter = 0 messageIdCounter = 0;
} }
/** 从服务端消息中提取 subagent_task_id如果该消息类型携带此字段 */ /** 从服务端消息中提取 subagent_task_id如果该消息类型携带此字段 */
export function getSubagentTaskId(message: WsOutbound): string | undefined { export function getSubagentTaskId(message: WsOutbound): string | undefined {
if (message.type === 'tool_call' || message.type === 'tool_result' if (
|| message.type === 'tool_pending' || message.type === 'assistant_response') { message.type === 'tool_call' ||
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id message.type === 'tool_result' ||
message.type === 'tool_pending' ||
message.type === 'assistant_response'
) {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id;
} }
if (message.type === 'stream_delta' || message.type === 'stream_end') { if (message.type === 'stream_delta' || message.type === 'stream_end') {
return (message as StreamDelta | StreamEnd).subagent_task_id return (message as StreamDelta | StreamEnd).subagent_task_id;
} }
if (message.type === 'execution_completed' || message.type === 'error') { if (message.type === 'execution_completed' || message.type === 'error') {
return (message as ExecutionCompleted | WsError).subagent_task_id return (message as ExecutionCompleted | WsError).subagent_task_id;
} }
return undefined return undefined;
} }
/** 将服务端消息转换为 UI ChatMessage不兼容的消息类型返回 null */ /** 将服务端消息转换为 UI ChatMessage不兼容的消息类型返回 null */
export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null { export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null {
switch (message.type) { switch (message.type) {
case 'assistant_response': { case 'assistant_response': {
const msg = message as AssistantResponse const msg = message as AssistantResponse;
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant' const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
return { return {
id: msg.id, id: msg.id,
role: role as ChatMessage['role'], role: role as ChatMessage['role'],
@ -54,10 +58,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
attachments: msg.attachments, attachments: msg.attachments,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content, reasoningContent: msg.reasoning_content,
} };
} }
case 'tool_call': { case 'tool_call': {
const msg = message as ToolCall const msg = message as ToolCall;
return { return {
id: msg.id, id: msg.id,
role: 'tool', role: 'tool',
@ -69,10 +73,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
arguments: msg.arguments, arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content, reasoningContent: msg.reasoning_content,
} };
} }
case 'tool_result': { case 'tool_result': {
const msg = message as ToolResult const msg = message as ToolResult;
return { return {
id: msg.id, id: msg.id,
role: 'tool', role: 'tool',
@ -83,10 +87,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms, durationMs: msg.duration_ms,
} };
} }
case 'tool_pending': { case 'tool_pending': {
const msg = message as ToolPending const msg = message as ToolPending;
return { return {
id: msg.id, id: msg.id,
role: 'tool', role: 'tool',
@ -96,10 +100,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
} };
} }
case 'stream_delta': { case 'stream_delta': {
const msg = message as StreamDelta const msg = message as StreamDelta;
return { return {
id: msg.id, id: msg.id,
role: 'assistant' as const, role: 'assistant' as const,
@ -108,7 +112,7 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
type: 'message' as const, type: 'message' as const,
subagentTaskId: msg.subagent_task_id, subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_delta, reasoningContent: msg.reasoning_delta,
} };
} }
case 'error': { case 'error': {
return { return {
@ -117,9 +121,9 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
content: `Error: ${message.message}`, content: `Error: ${message.message}`,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message', type: 'message',
} };
} }
default: default:
return null return null;
} }
} }

View File

@ -1,22 +1,22 @@
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol';
/** 子智能体视图(栈中的一层) */ /** 子智能体视图(栈中的一层) */
export interface SubAgentView { export interface SubAgentView {
taskId: string taskId: string;
description: string description: string;
subagentType: string subagentType: string;
status: string status: string;
summary?: string summary?: string;
messages: ChatMessage[] messages: ChatMessage[];
} }
/** 定时任务执行对话查看视图 */ /** 定时任务执行对话查看视图 */
export interface SchedulerJobView { export interface SchedulerJobView {
jobId: string jobId: string;
description: string description: string;
channel: string channel: string;
chatId: string chatId: string;
messages: ChatMessage[] messages: ChatMessage[];
} }
export const DEFAULT_CHAT_ID = 'default' export const DEFAULT_CHAT_ID = 'default';

View File

@ -1,28 +1,28 @@
import { useState, useCallback, useMemo, useRef } from 'react' import { useState, useCallback, useMemo, useRef } from 'react';
import type { WsInbound, Command } from '../../types/protocol' import type { WsInbound, Command } from '../../types/protocol';
export interface UseConnectionReturn { export interface UseConnectionReturn {
connectionId: string | null connectionId: string | null;
isConnected: boolean isConnected: boolean;
setConnectionId: (id: string | null) => void setConnectionId: (id: string | null) => void;
setSendMessage: (fn: (msg: WsInbound) => boolean) => void setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
/** 发送命令到后端(封装 command payload 序列化) */ /** 发送命令到后端(封装 command payload 序列化) */
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => void;
} }
export function useConnection(): UseConnectionReturn { export function useConnection(): UseConnectionReturn {
const [connectionId, setConnectionId] = useState<string | null>(null) const [connectionId, setConnectionId] = useState<string | null>(null);
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null) const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null);
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => { const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
sendMessageRef.current = fn sendMessageRef.current = fn;
}, []) }, []);
const sendCommand = useCallback((cmd: Command) => { const sendCommand = useCallback((cmd: Command) => {
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) }) sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) });
}, []) }, []);
const isConnected = useMemo(() => connectionId !== null, [connectionId]) const isConnected = useMemo(() => connectionId !== null, [connectionId]);
return { return {
connectionId, connectionId,
@ -30,5 +30,5 @@ export function useConnection(): UseConnectionReturn {
setConnectionId, setConnectionId,
setSendMessage, setSendMessage,
sendCommand, sendCommand,
} };
} }

View File

@ -1,4 +1,11 @@
import { useState, useCallback, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' import {
useState,
useCallback,
useRef,
type Dispatch,
type SetStateAction,
type MutableRefObject,
} from 'react';
import type { import type {
ChatMessage, ChatMessage,
WsOutbound, WsOutbound,
@ -13,48 +20,48 @@ import type {
TaskStarted, TaskStarted,
Attachment, Attachment,
Command, Command,
} from '../../types/protocol' } from '../../types/protocol';
import { generateMessageId, getSubagentTaskId } from './messageMappers' import { generateMessageId, getSubagentTaskId } from './messageMappers';
interface UseMessagesOptions { interface UseMessagesOptions {
selectedTopicRef: MutableRefObject<string | null> selectedTopicRef: MutableRefObject<string | null>;
topicsRef: MutableRefObject<Topic[]> topicsRef: MutableRefObject<Topic[]>;
bumpTopicRefreshTrigger: () => void bumpTopicRefreshTrigger: () => void;
} }
export interface UseMessagesReturn { export interface UseMessagesReturn {
messages: ChatMessage[] messages: ChatMessage[];
setMessages: Dispatch<SetStateAction<ChatMessage[]>> setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
isLoading: boolean isLoading: boolean;
setIsLoading: Dispatch<SetStateAction<boolean>> setIsLoading: Dispatch<SetStateAction<boolean>>;
handleMessage: (content: string, attachments?: Attachment[]) => void handleMessage: (content: string, attachments?: Attachment[]) => void;
clearMessages: () => void clearMessages: () => void;
handleStop: () => Command handleStop: () => Command;
/** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */ /** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */
handleMainViewMessage: (message: WsOutbound) => boolean handleMainViewMessage: (message: WsOutbound) => boolean;
} }
export function useMessages(options: UseMessagesOptions): UseMessagesReturn { export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false);
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set()) const syncedUserMessageIdsRef = useRef<Set<string>>(new Set());
const applyUserMessageId = useCallback((userMessageId: string) => { const applyUserMessageId = useCallback((userMessageId: string) => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return if (syncedUserMessageIdsRef.current.has(userMessageId)) return;
syncedUserMessageIdsRef.current.add(userMessageId) syncedUserMessageIdsRef.current.add(userMessageId);
setMessages(prev => { setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) { for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'user') { if (prev[i].role === 'user') {
const updated = [...prev] const updated = [...prev];
updated[i] = { ...updated[i], id: userMessageId } updated[i] = { ...updated[i], id: userMessageId };
return updated return updated;
} }
} }
return prev return prev;
}) });
}, []) }, []);
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => { const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [ setMessages((prev) => [
@ -67,222 +74,233 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
type: 'message', type: 'message',
attachments: attachments || [], attachments: attachments || [],
}, },
]) ]);
setIsLoading(true) setIsLoading(true);
}, []) }, []);
const clearMessages = useCallback(() => { const clearMessages = useCallback(() => {
setMessages([]) setMessages([]);
}, []) }, []);
const handleStop = useCallback((): Command => { const handleStop = useCallback((): Command => {
return { type: 'stop_execution' } return { type: 'stop_execution' };
}, []) }, []);
const handleMainViewMessage = useCallback((message: WsOutbound): boolean => { const handleMainViewMessage = useCallback(
switch (message.type) { (message: WsOutbound): boolean => {
case 'task_started': { switch (message.type) {
const msg = message as TaskStarted case 'task_started': {
// 只 backfill 当前话题的 task tool_call避免跨话题串扰 const msg = message as TaskStarted;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true // 只 backfill 当前话题的 task tool_call避免跨话题串扰
// 孙智能体的 TaskStarted 不应 backfill 到主视图 if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
if (msg.parent_task_id) return true // 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) return true;
setMessages((prev) => { setMessages((prev) => {
// 优先:按 tool_call_id 精确匹配 // 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) { if (msg.tool_call_id) {
const idx = prev.findIndex(m => const idx = prev.findIndex(
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task') (m) =>
if (idx >= 0 && !prev[idx].navigateToTaskId) { m.toolCallId === msg.tool_call_id &&
const updated = [...prev] m.type === 'tool_call' &&
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id } m.toolName === 'task',
return updated );
if (idx >= 0 && !prev[idx].navigateToTaskId) {
const updated = [...prev];
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id };
return updated;
}
} }
} // 回退backward-search (兼容无 tool_call_id 的旧版本)
// 回退backward-search (兼容无 tool_call_id 的旧版本) for (let i = prev.length - 1; i >= 0; i--) {
for (let i = prev.length - 1; i >= 0; i--) { if (
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) { prev[i].type === 'tool_call' &&
const updated = [...prev] prev[i].toolName === 'task' &&
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id } !prev[i].navigateToTaskId
return updated ) {
const updated = [...prev];
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id };
return updated;
}
} }
} return prev;
return prev });
}) return true;
return true }
}
case 'stream_delta': { case 'stream_delta': {
const msg = message as StreamDelta const msg = message as StreamDelta;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => { setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message') const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...prev] const updated = [...prev];
const existing = updated[existingIdx] const existing = updated[existingIdx];
updated[existingIdx] = { updated[existingIdx] = {
...existing, ...existing,
content: existing.content + msg.delta, content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta ? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent, : existing.reasoningContent,
};
return updated;
} }
return updated return [
...prev,
{
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
reasoningContent: msg.reasoning_delta,
},
];
});
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
return true;
}
case 'stream_end': {
return true;
}
case 'execution_completed': {
const msg = message as ExecutionCompleted;
if (getSubagentTaskId(message)) return true;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setIsLoading(false);
return true;
}
case 'assistant_response': {
const msg = message as AssistantResponse;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
const newMsg: ChatMessage = {
id: msg.id,
role,
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
reasoningContent: msg.reasoning_content,
};
if (existingIdx >= 0) {
const updated = [...prev];
updated[existingIdx] = newMsg;
return updated;
}
return [...prev, newMsg];
});
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find((t) => t.id === selectedTopicRef.current);
if (currentTopic && !currentTopic.description) {
bumpTopicRefreshTrigger();
} }
return [ if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
return true;
}
case 'tool_call': {
const msg = message as ToolCall;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [
...prev, ...prev,
{ {
id: msg.id, id: msg.id,
role: 'assistant' as const, role: 'tool',
content: msg.delta, content: msg.content,
timestamp: Math.floor(Date.now() / 1000), timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message' as const, type: 'tool_call',
reasoningContent: msg.reasoning_delta, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}, },
] ]);
}) if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
if (msg.user_message_id) applyUserMessageId(msg.user_message_id) return true;
return true
}
case 'stream_end': {
return true
}
case 'execution_completed': {
const msg = message as ExecutionCompleted
if (getSubagentTaskId(message)) return true
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setIsLoading(false)
return true
}
case 'assistant_response': {
const msg = message as AssistantResponse
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
const newMsg: ChatMessage = {
id: msg.id,
role,
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
attachments: msg.attachments,
reasoningContent: msg.reasoning_content,
}
if (existingIdx >= 0) {
const updated = [...prev]
updated[existingIdx] = newMsg
return updated
}
return [...prev, newMsg]
})
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
if (currentTopic && !currentTopic.description) {
bumpTopicRefreshTrigger()
} }
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
return true
}
case 'tool_call': { case 'tool_result': {
const msg = message as ToolCall const msg = message as ToolResult;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
id: msg.id, id: msg.id,
role: 'tool', role: 'tool',
content: msg.content, content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call', type: 'tool_result',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
arguments: msg.arguments, subagentTaskId: msg.subagent_task_id,
subagentTaskId: msg.subagent_task_id, durationMs: msg.duration_ms,
reasoningContent: msg.reasoning_content, },
}, ]);
]) return true;
if (msg.user_message_id) applyUserMessageId(msg.user_message_id) }
return true
}
case 'tool_result': { case 'tool_pending': {
const msg = message as ToolResult const msg = message as ToolPending;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ {
id: msg.id, id: msg.id,
role: 'tool', role: 'tool',
content: msg.content, content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result', type: 'tool_pending',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id, toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id, },
durationMs: msg.duration_ms, ]);
}, return true;
]) }
return true
}
case 'tool_pending': { case 'execution_cancelled': {
const msg = message as ToolPending setMessages((prev) => [
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true ...prev,
setMessages((prev) => [ {
...prev, id: generateMessageId(),
{ role: 'assistant',
id: msg.id, content: (message as { type: 'execution_cancelled'; message: string }).message,
role: 'tool', timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
content: `${msg.content}\n\n${msg.resume_hint}`, type: 'message',
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000), },
type: 'tool_pending', ]);
toolName: msg.tool_name, setIsLoading(false);
toolCallId: msg.tool_call_id, return true;
}, }
])
return true
}
case 'execution_cancelled': { case 'error': {
setMessages((prev) => [ if (getSubagentTaskId(message)) return true;
...prev, setMessages((prev) => [
{ ...prev,
id: generateMessageId(), {
role: 'assistant', id: generateMessageId(),
content: (message as { type: 'execution_cancelled'; message: string }).message, role: 'assistant',
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000), content: `Error: ${(message as WsError).message}`,
type: 'message', timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
}, type: 'message',
]) },
setIsLoading(false) ]);
return true setIsLoading(false);
} return true;
}
case 'error': { default:
if (getSubagentTaskId(message)) return true return false;
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: `Error: ${(message as WsError).message}`,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
return true
} }
},
default: [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId],
return false );
}
}, [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId])
return { return {
messages, messages,
@ -293,5 +311,5 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
clearMessages, clearMessages,
handleStop, handleStop,
handleMainViewMessage, handleMainViewMessage,
} };
} }

View File

@ -1,42 +1,54 @@
import { useState, useCallback, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' import {
useState,
useCallback,
useEffect,
useRef,
type Dispatch,
type SetStateAction,
type MutableRefObject,
} from 'react';
import type { import type {
WsOutbound, WsOutbound,
SchedulerJobSummary, SchedulerJobSummary,
SchedulerJobSessionLookup, SchedulerJobSessionLookup,
Command, Command,
} from '../../types/protocol' } from '../../types/protocol';
import { serverMessageToChatMessage } from './messageMappers' import { serverMessageToChatMessage } from './messageMappers';
import type { SchedulerJobView } from './types' import type { SchedulerJobView } from './types';
export interface UseSchedulerViewReturn { export interface UseSchedulerViewReturn {
schedulerView: SchedulerJobView | null schedulerView: SchedulerJobView | null;
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>> setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>;
schedulerViewRef: MutableRefObject<SchedulerJobView | null> schedulerViewRef: MutableRefObject<SchedulerJobView | null>;
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[];
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>> setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>;
sidebarTab: 'topics' | 'scheduler' sidebarTab: 'topics' | 'scheduler';
setSidebarTab: (tab: 'topics' | 'scheduler') => void setSidebarTab: (tab: 'topics' | 'scheduler') => void;
requestSchedulerJobList: () => Command requestSchedulerJobList: () => Command;
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command enterSchedulerJobView: (
exitSchedulerJobView: () => void lookup: SchedulerJobSessionLookup,
jobId: string,
description: string,
) => Command;
exitSchedulerJobView: () => void;
/** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */ /** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */
handleSchedulerMessage: (message: WsOutbound) => boolean handleSchedulerMessage: (message: WsOutbound) => boolean;
} }
export function useSchedulerView(): UseSchedulerViewReturn { export function useSchedulerView(): UseSchedulerViewReturn {
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null) const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null);
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]) const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]);
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics');
const schedulerViewRef = useRef<SchedulerJobView | null>(null) const schedulerViewRef = useRef<SchedulerJobView | null>(null);
useEffect(() => { useEffect(() => {
schedulerViewRef.current = schedulerView schedulerViewRef.current = schedulerView;
}, [schedulerView]) }, [schedulerView]);
const requestSchedulerJobList = useCallback((): Command => { const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' } return { type: 'list_scheduler_jobs' };
}, []) }, []);
const enterSchedulerJobView = useCallback( const enterSchedulerJobView = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => { (lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
@ -46,40 +58,38 @@ export function useSchedulerView(): UseSchedulerViewReturn {
channel: lookup.channel, channel: lookup.channel,
chatId: lookup.chat_id, chatId: lookup.chat_id,
messages: [], messages: [],
} };
schedulerViewRef.current = newView schedulerViewRef.current = newView;
setSchedulerView(newView) setSchedulerView(newView);
return { return {
type: 'load_chat_messages', type: 'load_chat_messages',
channel: lookup.channel, channel: lookup.channel,
chat_id: lookup.chat_id, chat_id: lookup.chat_id,
} };
}, },
[] [],
) );
const exitSchedulerJobView = useCallback(() => { const exitSchedulerJobView = useCallback(() => {
schedulerViewRef.current = null schedulerViewRef.current = null;
setSchedulerView(null) setSchedulerView(null);
}, []) }, []);
/** Tier 1 路由调度器视图激活时chat 消息追加到 schedulerView非 chat 消息 fall through */ /** Tier 1 路由调度器视图激活时chat 消息追加到 schedulerView非 chat 消息 fall through */
const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => { const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => {
const currentSchedulerView = schedulerViewRef.current const currentSchedulerView = schedulerViewRef.current;
if (!currentSchedulerView) return false if (!currentSchedulerView) return false;
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message);
if (chatMsg) { if (chatMsg) {
setSchedulerView((prev) => setSchedulerView((prev) =>
prev prev ? { ...prev, messages: [...prev.messages, chatMsg] } : prev,
? { ...prev, messages: [...prev.messages, chatMsg] } );
: prev return true;
)
return true
} }
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler // Non-chat messages (session_list, topic_list, etc.) fall through to main handler
return false return false;
}, []) }, []);
// scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置 // scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置
return { return {
@ -94,5 +104,5 @@ export function useSchedulerView(): UseSchedulerViewReturn {
enterSchedulerJobView, enterSchedulerJobView,
exitSchedulerJobView, exitSchedulerJobView,
handleSchedulerMessage, handleSchedulerMessage,
} };
} }

View File

@ -1,46 +1,49 @@
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
import type { SessionSummary, Command } from '../../types/protocol' import type { SessionSummary, Command } from '../../types/protocol';
export interface UseSessionsReturn { export interface UseSessionsReturn {
sessions: SessionSummary[] sessions: SessionSummary[];
setSessions: Dispatch<SetStateAction<SessionSummary[]>> setSessions: Dispatch<SetStateAction<SessionSummary[]>>;
selectedSessionId: string | null selectedSessionId: string | null;
setSelectedSessionId: Dispatch<SetStateAction<string | null>> setSelectedSessionId: Dispatch<SetStateAction<string | null>>;
session: SessionSummary | null session: SessionSummary | null;
sessionId: string | null sessionId: string | null;
chatId: string chatId: string;
selectSession: (sessionId: string) => void selectSession: (sessionId: string) => void;
requestSessionList: (selectedChannel: string) => Command requestSessionList: (selectedChannel: string) => Command;
} }
interface UseSessionsOptions { interface UseSessionsOptions {
/** selectSession 时额外执行的副作用 */ /** selectSession 时额外执行的副作用 */
onSessionChange?: () => void onSessionChange?: () => void;
} }
export function useSessions(options?: UseSessionsOptions): UseSessionsReturn { export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
const [sessions, setSessions] = useState<SessionSummary[]>([]) const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null) const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const selectedSession = useMemo( const selectedSession = useMemo(
() => sessions.find(s => s.session_id === selectedSessionId) ?? null, () => sessions.find((s) => s.session_id === selectedSessionId) ?? null,
[sessions, selectedSessionId] [sessions, selectedSessionId],
) );
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]) const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]);
const chatId = useMemo(() => sessionId ?? 'default', [sessionId]) const chatId = useMemo(() => sessionId ?? 'default', [sessionId]);
const selectSession = useCallback((id: string) => { const selectSession = useCallback(
setSelectedSessionId(id) (id: string) => {
options?.onSessionChange?.() setSelectedSessionId(id);
}, [options]) options?.onSessionChange?.();
},
[options],
);
const requestSessionList = useCallback((selectedChannel: string): Command => { const requestSessionList = useCallback((selectedChannel: string): Command => {
return { return {
type: 'list_sessions_by_channel', type: 'list_sessions_by_channel',
channel_name: selectedChannel, channel_name: selectedChannel,
include_archived: false, include_archived: false,
} };
}, []) }, []);
return { return {
sessions, sessions,
@ -52,5 +55,5 @@ export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
chatId, chatId,
selectSession, selectSession,
requestSessionList, requestSessionList,
} };
} }

View File

@ -1,82 +1,82 @@
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
import type { import type {
MemorySummary, MemorySummary,
SkillSummary, SkillSummary,
TodoItemSummary, TodoItemSummary,
Channel, Channel,
Command, Command,
} from '../../types/protocol' } from '../../types/protocol';
export interface UseSideDataReturn { export interface UseSideDataReturn {
memories: MemorySummary[] memories: MemorySummary[];
setMemories: Dispatch<SetStateAction<MemorySummary[]>> setMemories: Dispatch<SetStateAction<MemorySummary[]>>;
skills: SkillSummary[] skills: SkillSummary[];
setSkills: Dispatch<SetStateAction<SkillSummary[]>> setSkills: Dispatch<SetStateAction<SkillSummary[]>>;
todos: TodoItemSummary[] todos: TodoItemSummary[];
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>> setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
highlightedMessageId: string | null highlightedMessageId: string | null;
setHighlightedMessageId: Dispatch<SetStateAction<string | null>> setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
channels: Channel[] channels: Channel[];
setChannels: Dispatch<SetStateAction<Channel[]>> setChannels: Dispatch<SetStateAction<Channel[]>>;
selectedChannel: string selectedChannel: string;
setSelectedChannel: Dispatch<SetStateAction<string>> setSelectedChannel: Dispatch<SetStateAction<string>>;
isWritable: boolean isWritable: boolean;
requestMemoryList: () => Command requestMemoryList: () => Command;
createMemory: (namespace: string, key: string, content: string) => Command createMemory: (namespace: string, key: string, content: string) => Command;
updateMemory: (id: string, content: string) => Command updateMemory: (id: string, content: string) => Command;
deleteMemory: (id: string) => Command deleteMemory: (id: string) => Command;
requestSkillList: () => Command requestSkillList: () => Command;
requestTodoList: () => Command requestTodoList: () => Command;
requestSubAgentTodoList: (subTaskId: string) => Command requestSubAgentTodoList: (subTaskId: string) => Command;
requestChannelList: () => Command requestChannelList: () => Command;
} }
export function useSideData(): UseSideDataReturn { export function useSideData(): UseSideDataReturn {
const [memories, setMemories] = useState<MemorySummary[]>([]) const [memories, setMemories] = useState<MemorySummary[]>([]);
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([]);
const [todos, setTodos] = useState<TodoItemSummary[]>([]) const [todos, setTodos] = useState<TodoItemSummary[]>([]);
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null) const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
const [channels, setChannels] = useState<Channel[]>([]) const [channels, setChannels] = useState<Channel[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('websocket') const [selectedChannel, setSelectedChannel] = useState<string>('websocket');
const isWritable = useMemo( const isWritable = useMemo(
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false, () => channels.find((c) => c.id === selectedChannel)?.isWritable ?? false,
[channels, selectedChannel] [channels, selectedChannel],
) );
const requestMemoryList = useCallback((): Command => { const requestMemoryList = useCallback((): Command => {
return { type: 'list_memories' } return { type: 'list_memories' };
}, []) }, []);
const createMemory = useCallback((namespace: string, key: string, content: string): Command => { const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
return { type: 'create_memory', namespace, key, content } return { type: 'create_memory', namespace, key, content };
}, []) }, []);
const updateMemory = useCallback((id: string, content: string): Command => { const updateMemory = useCallback((id: string, content: string): Command => {
return { type: 'update_memory', id, content } return { type: 'update_memory', id, content };
}, []) }, []);
const deleteMemory = useCallback((id: string): Command => { const deleteMemory = useCallback((id: string): Command => {
return { type: 'delete_memory', id } return { type: 'delete_memory', id };
}, []) }, []);
const requestSkillList = useCallback((): Command => { const requestSkillList = useCallback((): Command => {
return { type: 'list_skills' } return { type: 'list_skills' };
}, []) }, []);
const requestTodoList = useCallback((): Command => { const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' } return { type: 'list_todos' };
}, []) }, []);
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => { const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
return { type: 'list_todos', task_id: subTaskId } return { type: 'list_todos', task_id: subTaskId };
}, []) }, []);
const requestChannelList = useCallback((): Command => { const requestChannelList = useCallback((): Command => {
return { type: 'list_channels' } return { type: 'list_channels' };
}, []) }, []);
return { return {
memories, memories,
@ -100,5 +100,5 @@ export function useSideData(): UseSideDataReturn {
requestTodoList, requestTodoList,
requestSubAgentTodoList, requestSubAgentTodoList,
requestChannelList, requestChannelList,
} };
} }

View File

@ -1,4 +1,13 @@
import { useState, useCallback, useMemo, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' import {
useState,
useCallback,
useMemo,
useEffect,
useRef,
type Dispatch,
type SetStateAction,
type MutableRefObject,
} from 'react';
import type { import type {
ChatMessage, ChatMessage,
WsOutbound, WsOutbound,
@ -8,370 +17,413 @@ import type {
TaskStarted, TaskStarted,
TaskMessagesLoaded, TaskMessagesLoaded,
Command, Command,
} from '../../types/protocol' } from '../../types/protocol';
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers' import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers';
import type { SubAgentView } from './types' import type { SubAgentView } from './types';
interface UseSubAgentViewOptions { interface UseSubAgentViewOptions {
/** 发送命令到后端(用于子代理 todo_write 后刷新待办) */ /** 发送命令到后端(用于子代理 todo_write 后刷新待办) */
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => void;
/** 构建子代理待办刷新命令 */ /** 构建子代理待办刷新命令 */
requestSubAgentTodoList: (subTaskId: string) => Command requestSubAgentTodoList: (subTaskId: string) => Command;
} }
export interface UseSubAgentViewReturn { export interface UseSubAgentViewReturn {
subAgentStack: SubAgentView[] subAgentStack: SubAgentView[];
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>> setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>;
subAgentView: SubAgentView | null subAgentView: SubAgentView | null;
subAgentViewRef: MutableRefObject<SubAgentView | null> subAgentViewRef: MutableRefObject<SubAgentView | null>;
subAgentStackRef: MutableRefObject<SubAgentView[]> subAgentStackRef: MutableRefObject<SubAgentView[]>;
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
exitSubAgentView: () => Command | null exitSubAgentView: () => Command | null;
navigateToSubAgentLevel: (index: number) => Command | null navigateToSubAgentLevel: (index: number) => Command | null;
/** 处理子智能体视图的消息路由Tier 2返回是否已处理 */ /** 处理子智能体视图的消息路由Tier 2返回是否已处理 */
handleSubAgentMessage: (message: WsOutbound) => boolean handleSubAgentMessage: (message: WsOutbound) => boolean;
} }
export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn { export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn {
const { sendCommand, requestSubAgentTodoList } = options const { sendCommand, requestSubAgentTodoList } = options;
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([]) const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([]);
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack]) const subAgentView = useMemo(
() => (subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null),
[subAgentStack],
);
const subAgentViewRef = useRef<SubAgentView | null>(null) const subAgentViewRef = useRef<SubAgentView | null>(null);
const subAgentStackRef = useRef<SubAgentView[]>([]) const subAgentStackRef = useRef<SubAgentView[]>([]);
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map()) const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
// ref 同步:确保回调中读到最新值 // ref 同步:确保回调中读到最新值
useEffect(() => { useEffect(() => {
subAgentViewRef.current = subAgentView subAgentViewRef.current = subAgentView;
}, [subAgentView]) }, [subAgentView]);
useEffect(() => { useEffect(() => {
subAgentStackRef.current = subAgentStack subAgentStackRef.current = subAgentStack;
}, [subAgentStack]) }, [subAgentStack]);
// 追加消息到栈顶视图(含流式累加) // 追加消息到栈顶视图(含流式累加)
const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => { const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => {
// stream_delta: accumulate into existing message by ID, or create new // stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta const msg = message as StreamDelta;
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev;
const top = prev[prev.length - 1] const top = prev[prev.length - 1];
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message') const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...top.messages] const updated = [...top.messages];
const existing = updated[existingIdx] const existing = updated[existingIdx];
updated[existingIdx] = { updated[existingIdx] = {
...existing, ...existing,
content: existing.content + msg.delta, content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta ? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent, : existing.reasoningContent,
} };
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updated } newStack[newStack.length - 1] = { ...top, messages: updated };
return newStack return newStack;
} }
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev if (!chatMsg) return prev;
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] } newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack return newStack;
}) });
return return;
} }
// stream_end: no-op, assistant_response will replace // stream_end: no-op, assistant_response will replace
if (message.type === 'stream_end') return if (message.type === 'stream_end') return;
// execution_completed: 更新栈顶 status 为 completed // execution_completed: 更新栈顶 status 为 completed
if (message.type === 'execution_completed') { if (message.type === 'execution_completed') {
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev;
const top = prev[prev.length - 1] const top = prev[prev.length - 1];
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, status: 'completed' } newStack[newStack.length - 1] = { ...top, status: 'completed' };
return newStack return newStack;
}) });
return return;
} }
// error: 更新栈顶 status 为 error并追加错误消息 // error: 更新栈顶 status 为 error并追加错误消息
if (message.type === 'error') { if (message.type === 'error') {
const errMsg = message as WsError const errMsg = message as WsError;
const errorChatMsg: ChatMessage = { const errorChatMsg: ChatMessage = {
id: generateMessageId(), id: generateMessageId(),
role: 'assistant', role: 'assistant',
content: `Error: ${errMsg.message}`, content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000), timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message', type: 'message',
} };
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev;
const top = prev[prev.length - 1] const top = prev[prev.length - 1];
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, status: 'error', messages: [...top.messages, errorChatMsg] } newStack[newStack.length - 1] = {
return newStack ...top,
}) status: 'error',
return messages: [...top.messages, errorChatMsg],
};
return newStack;
});
return;
} }
// Other messages: assistant_response replaces streamed message by ID // Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message);
if (chatMsg) { if (chatMsg) {
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev;
const top = prev[prev.length - 1] const top = prev[prev.length - 1];
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') const existingIdx = top.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...top.messages] const updated = [...top.messages];
updated[existingIdx] = chatMsg updated[existingIdx] = chatMsg;
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updated } newStack[newStack.length - 1] = { ...top, messages: updated };
return newStack return newStack;
} }
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') { } else if (
message.type === 'tool_call' ||
message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
// 按 id + type 去重,避免 load_task_messages 并发调用导致重复。 // 按 id + type 去重,避免 load_task_messages 并发调用导致重复。
const exists = top.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type) const exists = top.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
if (exists) return prev if (exists) return prev;
} }
const newStack = [...prev] const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] } newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack return newStack;
}) });
} }
}, []) }, []);
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配) // 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => { const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
const idx = prev.findIndex(v => v.taskId === taskId) const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev if (idx < 0) return prev;
const layer = prev[idx] const layer = prev[idx];
if (message.type === 'execution_completed') { if (message.type === 'execution_completed') {
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, status: 'completed' } newStack[idx] = { ...layer, status: 'completed' };
return newStack return newStack;
} }
if (message.type === 'error') { if (message.type === 'error') {
const errMsg = message as WsError const errMsg = message as WsError;
const errorChatMsg: ChatMessage = { const errorChatMsg: ChatMessage = {
id: generateMessageId(), id: generateMessageId(),
role: 'assistant', role: 'assistant',
content: `Error: ${errMsg.message}`, content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000), timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message', type: 'message',
} };
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] } newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
return newStack return newStack;
} }
if (message.type === 'stream_delta') { if (message.type === 'stream_delta') {
const msg = message as StreamDelta const msg = message as StreamDelta;
const existingIdx = layer.messages.findIndex(m => m.id === msg.id && m.type === 'message') const existingIdx = layer.messages.findIndex(
(m) => m.id === msg.id && m.type === 'message',
);
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...layer.messages] const updated = [...layer.messages];
const existing = updated[existingIdx] const existing = updated[existingIdx];
updated[existingIdx] = { updated[existingIdx] = {
...existing, ...existing,
content: existing.content + msg.delta, content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta ? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent, : existing.reasoningContent,
} };
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated } newStack[idx] = { ...layer, messages: updated };
return newStack return newStack;
} }
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev if (!chatMsg) return prev;
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] } newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack return newStack;
} }
if (message.type === 'stream_end') return prev if (message.type === 'stream_end') return prev;
const chatMsg = serverMessageToChatMessage(message) const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev if (!chatMsg) return prev;
if (message.type === 'assistant_response') { if (message.type === 'assistant_response') {
const existingIdx = layer.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message') const existingIdx = layer.messages.findIndex(
(m) => m.id === chatMsg.id && m.type === 'message',
);
if (existingIdx >= 0) { if (existingIdx >= 0) {
const updated = [...layer.messages] const updated = [...layer.messages];
updated[existingIdx] = chatMsg updated[existingIdx] = chatMsg;
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated } newStack[idx] = { ...layer, messages: updated };
return newStack return newStack;
} }
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') { } else if (
const exists = layer.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type) message.type === 'tool_call' ||
if (exists) return prev message.type === 'tool_result' ||
message.type === 'tool_pending'
) {
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
if (exists) return prev;
} }
const newStack = [...prev] const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] } newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack return newStack;
}) });
}, []) }, []);
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => { const enterSubAgentView = useCallback(
const newView: SubAgentView = { (taskId: string, description: string, subagentType?: string): Command => {
taskId, const newView: SubAgentView = {
description, taskId,
subagentType: subagentType || '', description,
status: 'loading', subagentType: subagentType || '',
messages: [], status: 'loading',
} messages: [],
// 同步设置 ref消除竞态窗口 };
subAgentViewRef.current = newView // 同步设置 ref消除竞态窗口
subAgentStackRef.current = [...subAgentStackRef.current, newView] subAgentViewRef.current = newView;
setSubAgentStack((prev) => [...prev, newView]) subAgentStackRef.current = [...subAgentStackRef.current, newView];
return { type: 'load_task_messages', task_id: taskId } setSubAgentStack((prev) => [...prev, newView]);
}, []) return { type: 'load_task_messages', task_id: taskId };
},
[],
);
const exitSubAgentView = useCallback((): Command | null => { const exitSubAgentView = useCallback((): Command | null => {
const current = subAgentStackRef.current const current = subAgentStackRef.current;
if (current.length <= 1) { if (current.length <= 1) {
subAgentViewRef.current = null subAgentViewRef.current = null;
subAgentStackRef.current = [] subAgentStackRef.current = [];
setSubAgentStack([]) setSubAgentStack([]);
return null return null;
} }
const newStack = current.slice(0, -1) const newStack = current.slice(0, -1);
const newTop = newStack[newStack.length - 1] const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop subAgentViewRef.current = newTop;
const clearedStack = [...newStack] const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack) setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId } return { type: 'load_task_messages', task_id: newTop.taskId };
}, []) }, []);
const navigateToSubAgentLevel = useCallback((index: number): Command | null => { const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
const current = subAgentStackRef.current const current = subAgentStackRef.current;
if (index < 0) { if (index < 0) {
subAgentViewRef.current = null subAgentViewRef.current = null;
subAgentStackRef.current = [] subAgentStackRef.current = [];
setSubAgentStack([]) setSubAgentStack([]);
return null return null;
} }
if (index >= current.length) return null if (index >= current.length) return null;
const newStack = current.slice(0, index + 1) const newStack = current.slice(0, index + 1);
const newTop = newStack[newStack.length - 1] const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop subAgentViewRef.current = newTop;
const clearedStack = [...newStack] const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' } clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack) setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId } return { type: 'load_task_messages', task_id: newTop.taskId };
}, []) }, []);
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */ /** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback((message: WsOutbound): boolean => { const handleSubAgentMessage = useCallback(
const currentSubAgentView = subAgentViewRef.current (message: WsOutbound): boolean => {
if (!currentSubAgentView) return false const currentSubAgentView = subAgentViewRef.current;
if (!currentSubAgentView) return false;
if (message.type === 'task_messages_loaded') { if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded const msg = message as TaskMessagesLoaded;
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (msg.task_id !== top.taskId) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = {
...top,
subagentType: msg.subagent_type,
status: msg.status,
summary: msg.summary,
}
return newStack
})
return true
}
if (message.type === 'task_started') {
const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) {
let matched = false
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev;
const top = prev[prev.length - 1] const top = prev[prev.length - 1];
const updatedMessages = [...top.messages] if (msg.task_id !== top.taskId) return prev;
const newStack = [...prev];
if (msg.tool_call_id) { newStack[newStack.length - 1] = {
const idx = updatedMessages.findIndex(m => ...top,
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task') subagentType: msg.subagent_type,
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) { status: msg.status,
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id } summary: msg.summary,
matched = true };
const newStack = [...prev] return newStack;
newStack[newStack.length - 1] = { ...top, messages: updatedMessages } });
return newStack return true;
}
}
for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
matched = true
break
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
})
if (!matched) {
const key = msg.tool_call_id || `fallback:${msg.task_id}`
pendingTaskNavsRef.current.set(key, msg.task_id)
}
return true
} }
}
const msgSubagentTaskId = getSubagentTaskId(message) if (message.type === 'task_started') {
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) { const msg = message as TaskStarted;
appendToSubAgentViewMessage(message) if (msg.parent_task_id === currentSubAgentView.taskId) {
let matched = false;
setSubAgentStack((prev) => {
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
const updatedMessages = [...top.messages];
// 检查 pending navigation当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId if (msg.tool_call_id) {
if (message.type === 'tool_call') { const idx = updatedMessages.findIndex(
const tc = message as ToolCall (m) =>
if (tc.tool_name === 'task' && tc.tool_call_id) { m.toolCallId === msg.tool_call_id &&
const key = tc.tool_call_id m.type === 'tool_call' &&
const pendingTaskId = pendingTaskNavsRef.current.get(key) m.toolName === 'task',
if (pendingTaskId) { );
pendingTaskNavsRef.current.delete(key) if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
setSubAgentStack((prev) => { updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id };
if (prev.length === 0) return prev matched = true;
const top = prev[prev.length - 1] const newStack = [...prev];
const updatedMessages = [...top.messages] newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
const idx = updatedMessages.findIndex(m => return newStack;
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
if (idx >= 0) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
} }
return prev }
}) for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i];
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id };
matched = true;
break;
}
}
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
return newStack;
});
if (!matched) {
const key = msg.tool_call_id || `fallback:${msg.task_id}`;
pendingTaskNavsRef.current.set(key, msg.task_id);
} }
return true;
} }
} }
// 子代理 todo_write 完成后自动刷新待办列表 const msgSubagentTaskId = getSubagentTaskId(message);
if (message.type === 'tool_result' && (message as { tool_name: string }).tool_name === 'todo_write') { if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId) appendToSubAgentViewMessage(message);
sendCommand(refreshCmd)
// 检查 pending navigation当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
if (message.type === 'tool_call') {
const tc = message as ToolCall;
if (tc.tool_name === 'task' && tc.tool_call_id) {
const key = tc.tool_call_id;
const pendingTaskId = pendingTaskNavsRef.current.get(key);
if (pendingTaskId) {
pendingTaskNavsRef.current.delete(key);
setSubAgentStack((prev) => {
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
const updatedMessages = [...top.messages];
const idx = updatedMessages.findIndex(
(m) => m.toolCallId === tc.tool_call_id && m.type === 'tool_call',
);
if (idx >= 0) {
updatedMessages[idx] = {
...updatedMessages[idx],
navigateToTaskId: pendingTaskId,
};
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
return newStack;
}
return prev;
});
}
}
}
// 子代理 todo_write 完成后自动刷新待办列表
if (
message.type === 'tool_result' &&
(message as { tool_name: string }).tool_name === 'todo_write'
) {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId);
sendCommand(refreshCmd);
}
return true;
} }
return true
}
// 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId // 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId
if (msgSubagentTaskId) { if (msgSubagentTaskId) {
appendToSubAgentLayerMessage(msgSubagentTaskId, message) appendToSubAgentLayerMessage(msgSubagentTaskId, message);
return true return true;
} }
// 消息不属于子智能体路由fall through 到主视图 // 消息不属于子智能体路由fall through 到主视图
return false return false;
}, [appendToSubAgentViewMessage, appendToSubAgentLayerMessage, sendCommand, requestSubAgentTodoList]) },
[
appendToSubAgentViewMessage,
appendToSubAgentLayerMessage,
sendCommand,
requestSubAgentTodoList,
],
);
return { return {
subAgentStack, subAgentStack,
@ -383,5 +435,5 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
exitSubAgentView, exitSubAgentView,
navigateToSubAgentLevel, navigateToSubAgentLevel,
handleSubAgentMessage, handleSubAgentMessage,
} };
} }

View File

@ -1,30 +1,38 @@
import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react' import {
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol' useState,
useCallback,
useRef,
useEffect,
type Dispatch,
type SetStateAction,
type MutableRefObject,
} from 'react';
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol';
export interface UseTopicsReturn { export interface UseTopicsReturn {
topics: Topic[] topics: Topic[];
setTopics: Dispatch<SetStateAction<Topic[]>> setTopics: Dispatch<SetStateAction<Topic[]>>;
selectedTopic: string | null selectedTopic: string | null;
setSelectedTopic: Dispatch<SetStateAction<string | null>> setSelectedTopic: Dispatch<SetStateAction<string | null>>;
topicRefreshTrigger: number topicRefreshTrigger: number;
bumpTopicRefreshTrigger: () => void bumpTopicRefreshTrigger: () => void;
topicsRef: MutableRefObject<Topic[]> topicsRef: MutableRefObject<Topic[]>;
selectedTopicRef: MutableRefObject<string | null> selectedTopicRef: MutableRefObject<string | null>;
pendingNewTopicRef: MutableRefObject<boolean> pendingNewTopicRef: MutableRefObject<boolean>;
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */ /** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
handleTopicList: (msg: TopicList) => boolean handleTopicList: (msg: TopicList) => boolean;
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */ /** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic */
handleTopicRenamed: (msg: TopicRenamed) => void handleTopicRenamed: (msg: TopicRenamed) => void;
createTopic: (title?: string) => Command createTopic: (title?: string) => Command;
switchTopic: (topicId: string) => Command switchTopic: (topicId: string) => Command;
deleteTopic: (topicId: string) => Command deleteTopic: (topicId: string) => Command;
renameTopic: (topicId: string, title: string) => Command renameTopic: (topicId: string, title: string) => Command;
requestTopicList: (sessionId: string | null) => Command | null requestTopicList: (sessionId: string | null) => Command | null;
} }
/** 将后端 TopicSummary[] 映射为前端 Topic[] */ /** 将后端 TopicSummary[] 映射为前端 Topic[] */
function mapTopicSummaries(summaries: TopicSummary[]): Topic[] { function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
return summaries.map(t => ({ return summaries.map((t) => ({
id: t.topic_id, id: t.topic_id,
session_id: t.session_id, session_id: t.session_id,
title: t.title, title: t.title,
@ -32,75 +40,77 @@ function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
message_count: Number(t.message_count), message_count: Number(t.message_count),
created_at: t.created_at, created_at: t.created_at,
updated_at: t.last_active_at, updated_at: t.last_active_at,
})) }));
} }
export function useTopics(): UseTopicsReturn { export function useTopics(): UseTopicsReturn {
const [topics, setTopics] = useState<Topic[]>([]) const [topics, setTopics] = useState<Topic[]>([]);
const [selectedTopic, setSelectedTopic] = useState<string | null>(null) const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0) const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0);
const topicsRef = useRef<Topic[]>([]) const topicsRef = useRef<Topic[]>([]);
const selectedTopicRef = useRef<string | null>(null) const selectedTopicRef = useRef<string | null>(null);
const pendingNewTopicRef = useRef(false) const pendingNewTopicRef = useRef(false);
// ref 同步:确保回调中读到最新值 // ref 同步:确保回调中读到最新值
useEffect(() => { useEffect(() => {
topicsRef.current = topics topicsRef.current = topics;
}, [topics]) }, [topics]);
useEffect(() => { useEffect(() => {
selectedTopicRef.current = selectedTopic selectedTopicRef.current = selectedTopic;
}, [selectedTopic]) }, [selectedTopic]);
const bumpTopicRefreshTrigger = useCallback(() => { const bumpTopicRefreshTrigger = useCallback(() => {
setTopicRefreshTrigger(n => n + 1) setTopicRefreshTrigger((n) => n + 1);
}, []) }, []);
const handleTopicList = useCallback((msg: TopicList): boolean => { const handleTopicList = useCallback((msg: TopicList): boolean => {
const newTopics = mapTopicSummaries(msg.topics) const newTopics = mapTopicSummaries(msg.topics);
setTopics(newTopics) setTopics(newTopics);
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新) // 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
if (pendingNewTopicRef.current) { if (pendingNewTopicRef.current) {
pendingNewTopicRef.current = false pendingNewTopicRef.current = false;
if (newTopics.length > 0) { if (newTopics.length > 0) {
setSelectedTopic(newTopics[0].id) setSelectedTopic(newTopics[0].id);
return true return true;
} }
} }
return false return false;
}, []) }, []);
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => { const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
// 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整 // 后端返回刷新后的完整列表直接替换selectedTopic 基于 id 不变,无需调整
setTopics(mapTopicSummaries(msg.topics)) setTopics(mapTopicSummaries(msg.topics));
}, []) }, []);
const createTopic = useCallback((title?: string): Command => { const createTopic = useCallback((title?: string): Command => {
pendingNewTopicRef.current = true pendingNewTopicRef.current = true;
return { return {
type: 'create_session', type: 'create_session',
title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`, title:
} title ||
}, []) `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
};
}, []);
const switchTopic = useCallback((topicId: string): Command => { const switchTopic = useCallback((topicId: string): Command => {
return { type: 'switch_topic', topic_id: topicId } return { type: 'switch_topic', topic_id: topicId };
}, []) }, []);
const deleteTopic = useCallback((topicId: string): Command => { const deleteTopic = useCallback((topicId: string): Command => {
return { type: 'delete_topic', topic_id: topicId } return { type: 'delete_topic', topic_id: topicId };
}, []) }, []);
const renameTopic = useCallback((topicId: string, title: string): Command => { const renameTopic = useCallback((topicId: string, title: string): Command => {
return { type: 'rename_topic', topic_id: topicId, title } return { type: 'rename_topic', topic_id: topicId, title };
}, []) }, []);
const requestTopicList = useCallback((sessionId: string | null): Command | null => { const requestTopicList = useCallback((sessionId: string | null): Command | null => {
if (!sessionId) return null if (!sessionId) return null;
return { type: 'list_topics', session_id: sessionId } return { type: 'list_topics', session_id: sessionId };
}, []) }, []);
return { return {
topics, topics,
@ -119,5 +129,5 @@ export function useTopics(): UseTopicsReturn {
deleteTopic, deleteTopic,
renameTopic, renameTopic,
requestTopicList, requestTopicList,
} };
} }

View File

@ -1,6 +1,6 @@
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useChat } from './useChat' import { useChat } from './useChat';
import type { import type {
WsInbound, WsInbound,
SessionEstablished, SessionEstablished,
@ -28,27 +28,27 @@ import type {
SchedulerJobSummary, SchedulerJobSummary,
SchedulerJobSessionLookup, SchedulerJobSessionLookup,
ExecutionCancelled, ExecutionCancelled,
} from '../types/protocol' } from '../types/protocol';
// ---- helpers ---- // ---- helpers ----
function renderUseChat() { function renderUseChat() {
const sendMessage = vi.fn((_msg: WsInbound) => true) const sendMessage = vi.fn((_msg: WsInbound) => true);
const { result } = renderHook(() => useChat()) const { result } = renderHook(() => useChat());
act(() => { act(() => {
result.current.setSendMessage(sendMessage) result.current.setSendMessage(sendMessage);
}) });
return { result, sendMessage } return { result, sendMessage };
} }
/** 取出 sendMessage 收到的最后一条 command payload已 JSON.parse */ /** 取出 sendMessage 收到的最后一条 command payload已 JSON.parse */
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown { function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
const calls = sendMessage.mock.calls const calls = sendMessage.mock.calls;
const last = calls.length > 0 ? calls[calls.length - 1][0] as WsInbound : undefined const last = calls.length > 0 ? (calls[calls.length - 1][0] as WsInbound) : undefined;
if (last && last.type === 'command') { if (last && last.type === 'command') {
return JSON.parse(last.payload) return JSON.parse(last.payload);
} }
return undefined return undefined;
} }
// ---- fixtures ---- // ---- fixtures ----
@ -56,7 +56,7 @@ function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
const sessionEstablished: SessionEstablished = { const sessionEstablished: SessionEstablished = {
type: 'session_established', type: 'session_established',
session_id: 'sess-1', session_id: 'sess-1',
} };
function makeSession(id: string): SessionSummary { function makeSession(id: string): SessionSummary {
return { return {
@ -66,13 +66,13 @@ function makeSession(id: string): SessionSummary {
chat_id: `chat-${id}`, chat_id: `chat-${id}`,
message_count: 0, message_count: 0,
last_active_at: 1000, last_active_at: 1000,
} };
} }
const sessionList: SessionList = { const sessionList: SessionList = {
type: 'session_list', type: 'session_list',
sessions: [makeSession('s1'), makeSession('s2')], sessions: [makeSession('s1'), makeSession('s2')],
} };
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary { function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
return { return {
@ -82,32 +82,32 @@ function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
message_count: 0, message_count: 0,
created_at: 1000, created_at: 1000,
last_active_at: 2000, last_active_at: 2000,
} };
} }
const topicList: TopicList = { const topicList: TopicList = {
type: 'topic_list', type: 'topic_list',
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')], topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1', session_id: 's1',
} };
const streamDelta1: StreamDelta = { const streamDelta1: StreamDelta = {
type: 'stream_delta', type: 'stream_delta',
id: 'm1', id: 'm1',
delta: 'Hello', delta: 'Hello',
} };
const streamDelta2: StreamDelta = { const streamDelta2: StreamDelta = {
type: 'stream_delta', type: 'stream_delta',
id: 'm1', id: 'm1',
delta: ' world', delta: ' world',
} };
const assistantResponse: AssistantResponse = { const assistantResponse: AssistantResponse = {
type: 'assistant_response', type: 'assistant_response',
id: 'm1', id: 'm1',
content: 'Hello world', content: 'Hello world',
role: 'assistant', role: 'assistant',
} };
const toolCall: ToolCall = { const toolCall: ToolCall = {
type: 'tool_call', type: 'tool_call',
@ -117,7 +117,7 @@ const toolCall: ToolCall = {
arguments: { x: 1 }, arguments: { x: 1 },
content: 'calling calculator', content: 'calling calculator',
role: 'tool', role: 'tool',
} };
const toolResult: ToolResult = { const toolResult: ToolResult = {
type: 'tool_result', type: 'tool_result',
@ -126,7 +126,7 @@ const toolResult: ToolResult = {
tool_name: 'calculator', tool_name: 'calculator',
content: '42', content: '42',
role: 'tool', role: 'tool',
} };
const toolPending: ToolPending = { const toolPending: ToolPending = {
type: 'tool_pending', type: 'tool_pending',
@ -136,38 +136,45 @@ const toolPending: ToolPending = {
content: 'waiting', content: 'waiting',
resume_hint: 'resume later', resume_hint: 'resume later',
role: 'tool', role: 'tool',
} };
const errorMsg: WsError = { const errorMsg: WsError = {
type: 'error', type: 'error',
code: 'ERR', code: 'ERR',
message: 'something broke', message: 'something broke',
} };
const executionCancelled: ExecutionCancelled = { const executionCancelled: ExecutionCancelled = {
type: 'execution_cancelled', type: 'execution_cancelled',
message: 'stopped by user', message: 'stopped by user',
} };
const memoryList: MemoryList = { const memoryList: MemoryList = {
type: 'memory_list', type: 'memory_list',
memories: [ memories: [
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 }, { id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
] as MemorySummary[], ] as MemorySummary[],
} };
const skillList: SkillList = { const skillList: SkillList = {
type: 'skill_list', type: 'skill_list',
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[], skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
} };
const todoList: TodoList = { const todoList: TodoList = {
type: 'todo_list', type: 'todo_list',
todos: [ todos: [
{ id: 'todo1', content: 'task', status: 'pending', priority: 'high', created_at: 1, updated_at: 2 }, {
id: 'todo1',
content: 'task',
status: 'pending',
priority: 'high',
created_at: 1,
updated_at: 2,
},
] as TodoItemSummary[], ] as TodoItemSummary[],
scope_key: 'main', scope_key: 'main',
} };
const channelList: ChannelList = { const channelList: ChannelList = {
type: 'channel_list', type: 'channel_list',
@ -175,127 +182,135 @@ const channelList: ChannelList = {
{ id: 'websocket', name: 'WebSocket', isWritable: true }, { id: 'websocket', name: 'WebSocket', isWritable: true },
{ id: 'cli', name: 'CLI', isWritable: false }, { id: 'cli', name: 'CLI', isWritable: false },
] as Channel[], ] as Channel[],
} };
const schedulerJobList: SchedulerJobList = { const schedulerJobList: SchedulerJobList = {
type: 'scheduler_job_list', type: 'scheduler_job_list',
jobs: [ jobs: [
{ id: 'job1', kind: 'one_off', schedule: {}, enabled: true, state: 'idle', run_count: 0, created_at: 1 } as SchedulerJobSummary, {
id: 'job1',
kind: 'one_off',
schedule: {},
enabled: true,
state: 'idle',
run_count: 0,
created_at: 1,
} as SchedulerJobSummary,
], ],
} };
// ---- tests ---- // ---- tests ----
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks();
}) });
describe('useChat - handleServerMessage characterization', () => { describe('useChat - handleServerMessage characterization', () => {
it('1. session_established sets connectionId and isConnected', () => { it('1. session_established sets connectionId and isConnected', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
expect(result.current.isConnected).toBe(false) expect(result.current.isConnected).toBe(false);
act(() => result.current.handleServerMessage(sessionEstablished)) act(() => result.current.handleServerMessage(sessionEstablished));
expect(result.current.connectionId).toBe('sess-1') expect(result.current.connectionId).toBe('sess-1');
expect(result.current.isConnected).toBe(true) expect(result.current.isConnected).toBe(true);
}) });
it('2. session_list fills sessions and auto-selects the first', () => { it('2. session_list fills sessions and auto-selects the first', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => result.current.handleServerMessage(sessionList)) act(() => result.current.handleServerMessage(sessionList));
expect(result.current.sessions).toHaveLength(2) expect(result.current.sessions).toHaveLength(2);
expect(result.current.selectedSessionId).toBe('s1') expect(result.current.selectedSessionId).toBe('s1');
expect(result.current.session?.session_id).toBe('s1') expect(result.current.session?.session_id).toBe('s1');
}) });
it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => { it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
// establish session + topic list to set baseline // establish session + topic list to set baseline
act(() => result.current.handleServerMessage(sessionEstablished)) act(() => result.current.handleServerMessage(sessionEstablished));
act(() => result.current.handleServerMessage(sessionList)) act(() => result.current.handleServerMessage(sessionList));
// first topic_list (without createTopic) sets topics but does NOT auto-select // first topic_list (without createTopic) sets topics but does NOT auto-select
act(() => result.current.handleServerMessage(topicList)) act(() => result.current.handleServerMessage(topicList));
expect(result.current.topics).toHaveLength(2) expect(result.current.topics).toHaveLength(2);
expect(result.current.selectedTopic).toBeNull() expect(result.current.selectedTopic).toBeNull();
// simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives // simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives
act(() => result.current.createTopic('new topic')) act(() => result.current.createTopic('new topic'));
const newTopicList: TopicList = { const newTopicList: TopicList = {
type: 'topic_list', type: 'topic_list',
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')], topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1', session_id: 's1',
} };
act(() => result.current.handleServerMessage(newTopicList)) act(() => result.current.handleServerMessage(newTopicList));
expect(result.current.selectedTopic).toBe('t3') expect(result.current.selectedTopic).toBe('t3');
}) });
it('4. stream_delta creates a message then accumulates into it by id', () => { it('4. stream_delta creates a message then accumulates into it by id', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => result.current.handleServerMessage(streamDelta1)) act(() => result.current.handleServerMessage(streamDelta1));
expect(result.current.messages).toHaveLength(1) expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello') expect(result.current.messages[0].content).toBe('Hello');
act(() => result.current.handleServerMessage(streamDelta2)) act(() => result.current.handleServerMessage(streamDelta2));
expect(result.current.messages).toHaveLength(1) expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello world') expect(result.current.messages[0].content).toBe('Hello world');
}) });
it('5. assistant_response replaces the streamed message by id', () => { it('5. assistant_response replaces the streamed message by id', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => result.current.handleServerMessage(streamDelta1)) act(() => result.current.handleServerMessage(streamDelta1));
act(() => result.current.handleServerMessage(streamDelta2)) act(() => result.current.handleServerMessage(streamDelta2));
act(() => result.current.handleServerMessage(assistantResponse)) act(() => result.current.handleServerMessage(assistantResponse));
expect(result.current.messages).toHaveLength(1) expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello world') expect(result.current.messages[0].content).toBe('Hello world');
expect(result.current.messages[0].id).toBe('m1') expect(result.current.messages[0].id).toBe('m1');
}) });
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => { it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => result.current.handleServerMessage(toolCall)) act(() => result.current.handleServerMessage(toolCall));
act(() => result.current.handleServerMessage(toolResult)) act(() => result.current.handleServerMessage(toolResult));
act(() => result.current.handleServerMessage(toolPending)) act(() => result.current.handleServerMessage(toolPending));
expect(result.current.messages).toHaveLength(3) expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].type).toBe('tool_call') expect(result.current.messages[0].type).toBe('tool_call');
expect(result.current.messages[0].toolName).toBe('calculator') expect(result.current.messages[0].toolName).toBe('calculator');
expect(result.current.messages[1].type).toBe('tool_result') expect(result.current.messages[1].type).toBe('tool_result');
expect(result.current.messages[2].type).toBe('tool_pending') expect(result.current.messages[2].type).toBe('tool_pending');
expect(result.current.messages[2].content).toContain('resume later') expect(result.current.messages[2].content).toContain('resume later');
}) });
it('7. error and execution_cancelled append a message and clear isLoading', () => { it('7. error and execution_cancelled append a message and clear isLoading', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
// set isLoading true via handleMessage // set isLoading true via handleMessage
act(() => result.current.handleMessage('hi')) act(() => result.current.handleMessage('hi'));
expect(result.current.isLoading).toBe(true) expect(result.current.isLoading).toBe(true);
act(() => result.current.handleServerMessage(errorMsg)) act(() => result.current.handleServerMessage(errorMsg));
expect(result.current.isLoading).toBe(false) expect(result.current.isLoading).toBe(false);
const errMsg = result.current.messages[result.current.messages.length - 1] const errMsg = result.current.messages[result.current.messages.length - 1];
expect(errMsg?.content).toBe('Error: something broke') expect(errMsg?.content).toBe('Error: something broke');
// reset isLoading + cleared, then test execution_cancelled // reset isLoading + cleared, then test execution_cancelled
act(() => result.current.handleMessage('hi again')) act(() => result.current.handleMessage('hi again'));
expect(result.current.isLoading).toBe(true) expect(result.current.isLoading).toBe(true);
act(() => result.current.handleServerMessage(executionCancelled)) act(() => result.current.handleServerMessage(executionCancelled));
expect(result.current.isLoading).toBe(false) expect(result.current.isLoading).toBe(false);
const cancelMsg = result.current.messages[result.current.messages.length - 1] const cancelMsg = result.current.messages[result.current.messages.length - 1];
expect(cancelMsg?.content).toBe('stopped by user') expect(cancelMsg?.content).toBe('stopped by user');
}) });
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => { it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => result.current.handleServerMessage(memoryList)) act(() => result.current.handleServerMessage(memoryList));
act(() => result.current.handleServerMessage(skillList)) act(() => result.current.handleServerMessage(skillList));
act(() => result.current.handleServerMessage(todoList)) act(() => result.current.handleServerMessage(todoList));
act(() => result.current.handleServerMessage(channelList)) act(() => result.current.handleServerMessage(channelList));
act(() => result.current.handleServerMessage(schedulerJobList)) act(() => result.current.handleServerMessage(schedulerJobList));
expect(result.current.memories).toHaveLength(1) expect(result.current.memories).toHaveLength(1);
expect(result.current.skills).toHaveLength(1) expect(result.current.skills).toHaveLength(1);
expect(result.current.todos).toHaveLength(1) expect(result.current.todos).toHaveLength(1);
expect(result.current.channels).toHaveLength(2) expect(result.current.channels).toHaveLength(2);
expect(result.current.schedulerJobs).toHaveLength(1) expect(result.current.schedulerJobs).toHaveLength(1);
}) });
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => { it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
const taskToolCall: ToolCall = { const taskToolCall: ToolCall = {
type: 'tool_call', type: 'tool_call',
id: 'tc-task', id: 'tc-task',
@ -304,9 +319,11 @@ describe('useChat - handleServerMessage characterization', () => {
arguments: { prompt: 'do sub' }, arguments: { prompt: 'do sub' },
content: 'spawning sub', content: 'spawning sub',
role: 'tool', role: 'tool',
} };
act(() => result.current.handleServerMessage(taskToolCall)) act(() => result.current.handleServerMessage(taskToolCall));
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBeUndefined() expect(
result.current.messages[result.current.messages.length - 1]?.navigateToTaskId,
).toBeUndefined();
const taskStarted: TaskStarted = { const taskStarted: TaskStarted = {
type: 'task_started', type: 'task_started',
@ -314,16 +331,18 @@ describe('useChat - handleServerMessage characterization', () => {
description: 'sub agent', description: 'sub agent',
subagent_type: 'general', subagent_type: 'general',
tool_call_id: 'tc-task', tool_call_id: 'tc-task',
} };
act(() => result.current.handleServerMessage(taskStarted)) act(() => result.current.handleServerMessage(taskStarted));
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe('sub-1') expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe(
}) 'sub-1',
);
});
it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => { it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
// enter sub-agent view for task "sub-1" // enter sub-agent view for task "sub-1"
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general')) act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'));
expect(result.current.subAgentView?.taskId).toBe('sub-1') expect(result.current.subAgentView?.taskId).toBe('sub-1');
// task_messages_loaded updates top metadata // task_messages_loaded updates top metadata
const loaded: TaskMessagesLoaded = { const loaded: TaskMessagesLoaded = {
@ -333,10 +352,10 @@ describe('useChat - handleServerMessage characterization', () => {
subagent_type: 'general', subagent_type: 'general',
status: 'running', status: 'running',
summary: 'working', summary: 'working',
} };
act(() => result.current.handleServerMessage(loaded)) act(() => result.current.handleServerMessage(loaded));
expect(result.current.subAgentView?.status).toBe('running') expect(result.current.subAgentView?.status).toBe('running');
expect(result.current.subAgentView?.summary).toBe('working') expect(result.current.subAgentView?.summary).toBe('working');
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main // a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
const subStream: StreamDelta = { const subStream: StreamDelta = {
@ -344,33 +363,33 @@ describe('useChat - handleServerMessage characterization', () => {
id: 'sub-m1', id: 'sub-m1',
delta: 'sub hello', delta: 'sub hello',
subagent_task_id: 'sub-1', subagent_task_id: 'sub-1',
} };
act(() => result.current.handleServerMessage(subStream)) act(() => result.current.handleServerMessage(subStream));
expect(result.current.subAgentView?.messages).toHaveLength(1) expect(result.current.subAgentView?.messages).toHaveLength(1);
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello') expect(result.current.subAgentView?.messages[0].content).toBe('sub hello');
// exit back to main: main messages should not contain the sub-agent message // exit back to main: main messages should not contain the sub-agent message
act(() => result.current.exitSubAgentView()) act(() => result.current.exitSubAgentView());
expect(result.current.messages.find(m => m.id === 'sub-m1')).toBeUndefined() expect(result.current.messages.find((m) => m.id === 'sub-m1')).toBeUndefined();
}) });
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => { it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' } const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' };
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc')) act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'));
expect(result.current.schedulerView).not.toBeNull() expect(result.current.schedulerView).not.toBeNull();
act(() => result.current.handleServerMessage(assistantResponse)) act(() => result.current.handleServerMessage(assistantResponse));
expect(result.current.schedulerView?.messages).toHaveLength(1) expect(result.current.schedulerView?.messages).toHaveLength(1);
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world') expect(result.current.schedulerView?.messages[0].content).toBe('Hello world');
// exit scheduler view: main messages should not contain the routed message // exit scheduler view: main messages should not contain the routed message
act(() => result.current.exitSchedulerJobView()) act(() => result.current.exitSchedulerJobView());
expect(result.current.messages.find(m => m.id === 'm1')).toBeUndefined() expect(result.current.messages.find((m) => m.id === 'm1')).toBeUndefined();
}) });
it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => { it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => {
const { result, sendMessage } = renderUseChat() const { result, sendMessage } = renderUseChat();
const todoWriteResult: ToolResult = { const todoWriteResult: ToolResult = {
type: 'tool_result', type: 'tool_result',
id: 'tr-todo', id: 'tr-todo',
@ -378,30 +397,30 @@ describe('useChat - handleServerMessage characterization', () => {
tool_name: 'todo_write', tool_name: 'todo_write',
content: 'updated', content: 'updated',
role: 'tool', role: 'tool',
} };
act(() => result.current.handleServerMessage(todoWriteResult)) act(() => result.current.handleServerMessage(todoWriteResult));
const cmd = lastCommand(sendMessage) const cmd = lastCommand(sendMessage);
expect(cmd).toEqual({ type: 'list_todos' }) expect(cmd).toEqual({ type: 'list_todos' });
}) });
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => { it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
const { result } = renderUseChat() const { result } = renderUseChat();
act(() => { act(() => {
result.current.handleServerMessage(sessionEstablished) result.current.handleServerMessage(sessionEstablished);
result.current.handleServerMessage(sessionList) result.current.handleServerMessage(sessionList);
result.current.handleServerMessage(topicList) result.current.handleServerMessage(topicList);
}) });
// topic_list without createTopic does NOT auto-select; manually select t1 // topic_list without createTopic does NOT auto-select; manually select t1
act(() => result.current.selectTopic('t1')) act(() => result.current.selectTopic('t1'));
expect(result.current.selectedTopic).toBe('t1') expect(result.current.selectedTopic).toBe('t1');
const otherTopicStream: StreamDelta = { const otherTopicStream: StreamDelta = {
type: 'stream_delta', type: 'stream_delta',
id: 'm-other', id: 'm-other',
delta: 'should be dropped', delta: 'should be dropped',
topic_id: 't-other', topic_id: 't-other',
} };
act(() => result.current.handleServerMessage(otherTopicStream)) act(() => result.current.handleServerMessage(otherTopicStream));
expect(result.current.messages.find(m => m.id === 'm-other')).toBeUndefined() expect(result.current.messages.find((m) => m.id === 'm-other')).toBeUndefined();
}) });
}) });

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react' import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
import type { import type {
Command, Command,
ChatMessage, ChatMessage,
@ -13,201 +13,207 @@ import type {
SchedulerJobSummary, SchedulerJobSummary,
SchedulerJobSessionLookup, SchedulerJobSessionLookup,
Channel, Channel,
} from '../types/protocol' } from '../types/protocol';
import type { SubAgentView, SchedulerJobView } from './chat/types' import type { SubAgentView, SchedulerJobView } from './chat/types';
import { getSubagentTaskId } from './chat/messageMappers' import { getSubagentTaskId } from './chat/messageMappers';
import { useConnection } from './chat/useConnection' import { useConnection } from './chat/useConnection';
import { useSideData } from './chat/useSideData' import { useSideData } from './chat/useSideData';
import { useSessions } from './chat/useSessions' import { useSessions } from './chat/useSessions';
import { useTopics } from './chat/useTopics' import { useTopics } from './chat/useTopics';
import { useMessages } from './chat/useMessages' import { useMessages } from './chat/useMessages';
import { useSubAgentView } from './chat/useSubAgentView' import { useSubAgentView } from './chat/useSubAgentView';
import { useSchedulerView } from './chat/useSchedulerView' import { useSchedulerView } from './chat/useSchedulerView';
// 简化后的层级状态 // 简化后的层级状态
interface UseChatReturn { interface UseChatReturn {
// 连接状态 // 连接状态
connectionId: string | null connectionId: string | null;
isConnected: boolean isConnected: boolean;
// 简化的层级状态 // 简化的层级状态
sessions: SessionSummary[] sessions: SessionSummary[];
selectedSessionId: string | null selectedSessionId: string | null;
session: SessionSummary | null session: SessionSummary | null;
sessionId: string | null sessionId: string | null;
chatId: string chatId: string;
topics: Topic[] topics: Topic[];
selectedTopic: string | null selectedTopic: string | null;
// 消息 // 消息
messages: ChatMessage[] messages: ChatMessage[];
isLoading: boolean isLoading: boolean;
// 通道状态 // 通道状态
channels: Channel[] channels: Channel[];
selectedChannel: string selectedChannel: string;
isWritable: boolean isWritable: boolean;
// 是否只读 // 是否只读
isReadOnly: boolean isReadOnly: boolean;
// 子智能体视图(栈结构,支持面包屑导航) // 子智能体视图(栈结构,支持面包屑导航)
subAgentView: SubAgentView | null subAgentView: SubAgentView | null;
subAgentStack: SubAgentView[] subAgentStack: SubAgentView[];
// 方法 // 方法
handleMessage: (content: string, attachments?: Attachment[]) => void handleMessage: (content: string, attachments?: Attachment[]) => void;
handleCommand: (command: Command) => void handleCommand: (command: Command) => void;
clearMessages: () => void clearMessages: () => void;
handleServerMessage: (message: WsOutbound) => void handleServerMessage: (message: WsOutbound) => void;
setSendMessage: (fn: (msg: WsInbound) => boolean) => void setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
// Topic 方法 // Topic 方法
selectTopic: (topicId: string) => void selectTopic: (topicId: string) => void;
createTopic: (title?: string) => Command createTopic: (title?: string) => Command;
switchTopic: (topicId: string) => Command switchTopic: (topicId: string) => Command;
deleteTopic: (topicId: string) => Command deleteTopic: (topicId: string) => Command;
renameTopic: (topicId: string, title: string) => Command renameTopic: (topicId: string, title: string) => Command;
// 初始化方法 // 初始化方法
requestSessionList: () => Command requestSessionList: () => Command;
requestTopicList: () => Command | null requestTopicList: () => Command | null;
topicRefreshTrigger: number topicRefreshTrigger: number;
requestChannelList: () => Command requestChannelList: () => Command;
selectChannel: (channelId: string) => void selectChannel: (channelId: string) => void;
selectSession: (sessionId: string) => void selectSession: (sessionId: string) => void;
// 子智能体导航方法 // 子智能体导航方法
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
exitSubAgentView: () => Command | null exitSubAgentView: () => Command | null;
navigateToSubAgentLevel: (index: number) => Command | null navigateToSubAgentLevel: (index: number) => Command | null;
// 记忆状态 // 记忆状态
memories: MemorySummary[] memories: MemorySummary[];
requestMemoryList: () => Command requestMemoryList: () => Command;
createMemory: (namespace: string, key: string, content: string) => Command createMemory: (namespace: string, key: string, content: string) => Command;
updateMemory: (id: string, content: string) => Command updateMemory: (id: string, content: string) => Command;
deleteMemory: (id: string) => Command deleteMemory: (id: string) => Command;
// 技能状态 // 技能状态
skills: SkillSummary[] skills: SkillSummary[];
requestSkillList: () => Command requestSkillList: () => Command;
// Todo 状态 // Todo 状态
todos: TodoItemSummary[] todos: TodoItemSummary[];
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>> setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
requestTodoList: () => Command requestTodoList: () => Command;
requestSubAgentTodoList: (subTaskId: string) => Command requestSubAgentTodoList: (subTaskId: string) => Command;
// 高亮消息 ID点击待办后滚动到对应消息 // 高亮消息 ID点击待办后滚动到对应消息
highlightedMessageId: string | null highlightedMessageId: string | null;
setHighlightedMessageId: Dispatch<SetStateAction<string | null>> setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
// 定时任务状态 // 定时任务状态
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[];
sidebarTab: 'topics' | 'scheduler' sidebarTab: 'topics' | 'scheduler';
setSidebarTab: (tab: 'topics' | 'scheduler') => void setSidebarTab: (tab: 'topics' | 'scheduler') => void;
requestSchedulerJobList: () => Command requestSchedulerJobList: () => Command;
// 定时任务执行对话查看 // 定时任务执行对话查看
schedulerView: SchedulerJobView | null schedulerView: SchedulerJobView | null;
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command enterSchedulerJobView: (
exitSchedulerJobView: () => void lookup: SchedulerJobSessionLookup,
jobId: string,
description: string,
) => Command;
exitSchedulerJobView: () => void;
// 停止当前 Agent 执行 // 停止当前 Agent 执行
handleStop: () => Command handleStop: () => Command;
} }
export function useChat(): UseChatReturn { export function useChat(): UseChatReturn {
// 调用顺序确保依赖方向useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList // 调用顺序确保依赖方向useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList
const conn = useConnection() const conn = useConnection();
const sideData = useSideData() const sideData = useSideData();
const sessions = useSessions() const sessions = useSessions();
const topics = useTopics() const topics = useTopics();
const messages = useMessages({ const messages = useMessages({
selectedTopicRef: topics.selectedTopicRef, selectedTopicRef: topics.selectedTopicRef,
topicsRef: topics.topicsRef, topicsRef: topics.topicsRef,
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger, bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
}) });
const subAgent = useSubAgentView({ const subAgent = useSubAgentView({
sendCommand: conn.sendCommand, sendCommand: conn.sendCommand,
requestSubAgentTodoList: sideData.requestSubAgentTodoList, requestSubAgentTodoList: sideData.requestSubAgentTodoList,
}) });
const scheduler = useSchedulerView() const scheduler = useSchedulerView();
// ---- handleServerMessage: 纯路由分发Tier 1 → Tier 2 → Tier 3 ---- // ---- handleServerMessage: 纯路由分发Tier 1 → Tier 2 → Tier 3 ----
// 所有被调用的方法都是稳定引用useState setter 或 useCallback([]) // 所有被调用的方法都是稳定引用useState setter 或 useCallback([])
// 因此空依赖数组安全,不会产生过期闭包。 // 因此空依赖数组安全,不会产生过期闭包。
const handleServerMessage = useCallback((message: WsOutbound) => { const handleServerMessage = useCallback((message: WsOutbound) => {
// Tier 1: 调度器视图激活时chat 消息路由到 schedulerView // Tier 1: 调度器视图激活时chat 消息路由到 schedulerView
if (scheduler.handleSchedulerMessage(message)) return if (scheduler.handleSchedulerMessage(message)) return;
// Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack // Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack
if (subAgent.handleSubAgentMessage(message)) return if (subAgent.handleSubAgentMessage(message)) return;
// Tier 3: 主视图路由 // Tier 3: 主视图路由
// 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中) // 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中)
if (getSubagentTaskId(message)) return if (getSubagentTaskId(message)) return;
// 3b: 非 chat 消息的 case 分发 // 3b: 非 chat 消息的 case 分发
switch (message.type) { switch (message.type) {
case 'session_established': case 'session_established':
conn.setConnectionId(message.session_id) conn.setConnectionId(message.session_id);
return return;
case 'session_list': case 'session_list':
// 清空旧数据(切换通道时避免数据污染) // 清空旧数据(切换通道时避免数据污染)
topics.setTopics([]) topics.setTopics([]);
topics.setSelectedTopic(null) topics.setSelectedTopic(null);
messages.setMessages([]) messages.setMessages([]);
sessions.setSessions(message.sessions) sessions.setSessions(message.sessions);
// 自动选中:优先保持当前选中,否则选第一个 // 自动选中:优先保持当前选中,否则选第一个
sessions.setSelectedSessionId(prev => sessions.setSelectedSessionId((prev) =>
prev && message.sessions.some(s => s.session_id === prev) prev && message.sessions.some((s) => s.session_id === prev)
? prev ? prev
: message.sessions.length > 0 ? message.sessions[0].session_id : null : message.sessions.length > 0
) ? message.sessions[0].session_id
messages.setIsLoading(false) : null,
return );
messages.setIsLoading(false);
return;
case 'session_created': case 'session_created':
case 'session_loaded': case 'session_loaded':
messages.setIsLoading(false) messages.setIsLoading(false);
return return;
case 'topic_list': { case 'topic_list': {
const autoFocused = topics.handleTopicList(message) const autoFocused = topics.handleTopicList(message);
if (autoFocused) messages.setMessages([]) if (autoFocused) messages.setMessages([]);
messages.setIsLoading(false) messages.setIsLoading(false);
return return;
} }
case 'topic_renamed': case 'topic_renamed':
topics.handleTopicRenamed(message) topics.handleTopicRenamed(message);
return return;
case 'scheduler_job_list': case 'scheduler_job_list':
scheduler.setSchedulerJobs(message.jobs) scheduler.setSchedulerJobs(message.jobs);
return return;
case 'memory_list': case 'memory_list':
sideData.setMemories(message.memories) sideData.setMemories(message.memories);
return return;
case 'skill_list': case 'skill_list':
sideData.setSkills(message.skills) sideData.setSkills(message.skills);
return return;
case 'todo_list': case 'todo_list':
sideData.setTodos(message.todos) sideData.setTodos(message.todos);
return return;
case 'channel_list': case 'channel_list':
sideData.setChannels(message.channels) sideData.setChannels(message.channels);
return return;
case 'pong': case 'pong':
return return;
default: default:
// 3c: chat 类消息task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled // 3c: chat 类消息task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled
@ -216,17 +222,20 @@ export function useChat(): UseChatReturn {
// 注意topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true // 注意topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true
// 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数, // 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数,
// 因此这里需要再次检查 topic_id 以保持行为等价。 // 因此这里需要再次检查 topic_id 以保持行为等价。
if (message.type === 'tool_result' && message.tool_name === 'todo_write' if (
&& (!message.topic_id || message.topic_id === topics.selectedTopicRef.current)) { message.type === 'tool_result' &&
message.tool_name === 'todo_write' &&
(!message.topic_id || message.topic_id === topics.selectedTopicRef.current)
) {
const cmd = subAgent.subAgentViewRef.current?.taskId const cmd = subAgent.subAgentViewRef.current?.taskId
? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId) ? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId)
: sideData.requestTodoList() : sideData.requestTodoList();
conn.sendCommand(cmd) conn.sendCommand(cmd);
} }
} }
return return;
} }
}, []) }, []);
// ---- handleCommand: 根据命令类型设置 loading 状态 ---- // ---- handleCommand: 根据命令类型设置 loading 状态 ----
const handleCommand = useCallback((command: Command) => { const handleCommand = useCallback((command: Command) => {
@ -238,70 +247,76 @@ export function useChat(): UseChatReturn {
case 'list_sessions_by_channel': case 'list_sessions_by_channel':
case 'delete_topic': case 'delete_topic':
case 'list_topics': case 'list_topics':
messages.setIsLoading(true) messages.setIsLoading(true);
break break;
} }
}, []) }, []);
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ---- // ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
topics.setSelectedTopic(topicId) topics.setSelectedTopic(topicId);
messages.setMessages([]) messages.setMessages([]);
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致) // ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
subAgent.subAgentViewRef.current = null subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = [] subAgent.subAgentStackRef.current = [];
subAgent.setSubAgentStack([]) subAgent.setSubAgentStack([]);
}, []) }, []);
// ---- selectChannel: 切换通道,清空全部状态 ---- // ---- selectChannel: 切换通道,清空全部状态 ----
const selectChannel = useCallback((channelId: string) => { const selectChannel = useCallback(
if (channelId === sideData.selectedChannel) return (channelId: string) => {
sideData.setSelectedChannel(channelId) if (channelId === sideData.selectedChannel) return;
sessions.setSessions([]) sideData.setSelectedChannel(channelId);
sessions.setSelectedSessionId(null) sessions.setSessions([]);
topics.setTopics([]) sessions.setSelectedSessionId(null);
topics.setSelectedTopic(null) topics.setTopics([]);
messages.setMessages([]) topics.setSelectedTopic(null);
subAgent.subAgentViewRef.current = null messages.setMessages([]);
subAgent.subAgentStackRef.current = [] subAgent.subAgentViewRef.current = null;
subAgent.setSubAgentStack([]) subAgent.subAgentStackRef.current = [];
messages.setIsLoading(true) subAgent.setSubAgentStack([]);
}, [sideData.selectedChannel]) messages.setIsLoading(true);
},
[sideData.selectedChannel],
);
// ---- selectSession: 切换会话,清空 topics/messages/subAgent ---- // ---- selectSession: 切换会话,清空 topics/messages/subAgent ----
const selectSession = useCallback((sessionId: string) => { const selectSession = useCallback(
if (sessionId === sessions.selectedSessionId) return (sessionId: string) => {
sessions.setSelectedSessionId(sessionId) if (sessionId === sessions.selectedSessionId) return;
topics.setTopics([]) sessions.setSelectedSessionId(sessionId);
topics.setSelectedTopic(null) topics.setTopics([]);
messages.setMessages([]) topics.setSelectedTopic(null);
subAgent.subAgentViewRef.current = null messages.setMessages([]);
subAgent.subAgentStackRef.current = [] subAgent.subAgentViewRef.current = null;
subAgent.setSubAgentStack([]) subAgent.subAgentStackRef.current = [];
messages.setIsLoading(true) subAgent.setSubAgentStack([]);
}, [sessions.selectedSessionId]) messages.setIsLoading(true);
},
[sessions.selectedSessionId],
);
// ---- 委托方法 ---- // ---- 委托方法 ----
const requestSessionList = useCallback((): Command => { const requestSessionList = useCallback((): Command => {
return sessions.requestSessionList(sideData.selectedChannel) return sessions.requestSessionList(sideData.selectedChannel);
}, [sideData.selectedChannel]) }, [sideData.selectedChannel]);
const requestTopicList = useCallback((): Command | null => { const requestTopicList = useCallback((): Command | null => {
return topics.requestTopicList(sessions.sessionId) return topics.requestTopicList(sessions.sessionId);
}, [sessions.sessionId]) }, [sessions.sessionId]);
const requestChannelList = useCallback((): Command => { const requestChannelList = useCallback((): Command => {
return sideData.requestChannelList() return sideData.requestChannelList();
}, []) }, []);
// ---- 派生状态 ---- // ---- 派生状态 ----
const resolvedMessages = useMemo(() => { const resolvedMessages = useMemo(() => {
if (subAgent.subAgentView) return subAgent.subAgentView.messages if (subAgent.subAgentView) return subAgent.subAgentView.messages;
if (scheduler.schedulerView) return scheduler.schedulerView.messages if (scheduler.schedulerView) return scheduler.schedulerView.messages;
return messages.messages return messages.messages;
}, [subAgent.subAgentView, scheduler.schedulerView, messages.messages]) }, [subAgent.subAgentView, scheduler.schedulerView, messages.messages]);
const isReadOnly = !sideData.isWritable const isReadOnly = !sideData.isWritable;
// ---- 组装返回对象 ---- // ---- 组装返回对象 ----
return { return {
@ -362,5 +377,5 @@ export function useChat(): UseChatReturn {
enterSchedulerJobView: scheduler.enterSchedulerJobView, enterSchedulerJobView: scheduler.enterSchedulerJobView,
exitSchedulerJobView: scheduler.exitSchedulerJobView, exitSchedulerJobView: scheduler.exitSchedulerJobView,
handleStop: messages.handleStop, handleStop: messages.handleStop,
} };
} }

View File

@ -1,21 +1,21 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react';
import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol' import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol';
interface UseWebSocketOptions { interface UseWebSocketOptions {
url: string url: string;
onMessage?: (message: WsOutbound) => void onMessage?: (message: WsOutbound) => void;
onConnect?: () => void onConnect?: () => void;
onDisconnect?: () => void onDisconnect?: () => void;
onError?: (error: Event) => void onError?: (error: Event) => void;
reconnectInterval?: number reconnectInterval?: number;
maxReconnectAttempts?: number maxReconnectAttempts?: number;
} }
interface UseWebSocketReturn { interface UseWebSocketReturn {
status: ConnectionStatus status: ConnectionStatus;
sendMessage: (message: WsInbound) => boolean sendMessage: (message: WsInbound) => boolean;
connect: () => void connect: () => void;
disconnect: () => void disconnect: () => void;
} }
export function useWebSocket({ export function useWebSocket({
@ -27,113 +27,113 @@ export function useWebSocket({
reconnectInterval = 3000, reconnectInterval = 3000,
maxReconnectAttempts = 5, maxReconnectAttempts = 5,
}: UseWebSocketOptions): UseWebSocketReturn { }: UseWebSocketOptions): UseWebSocketReturn {
const [status, setStatus] = useState<ConnectionStatus>('disconnected') const [status, setStatus] = useState<ConnectionStatus>('disconnected');
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptsRef = useRef(0) const reconnectAttemptsRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isManualDisconnectRef = useRef(false) const isManualDisconnectRef = useRef(false);
const connect = useCallback(() => { const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
return return;
} }
isManualDisconnectRef.current = false isManualDisconnectRef.current = false;
setStatus('connecting') setStatus('connecting');
try { try {
const ws = new WebSocket(url) const ws = new WebSocket(url);
wsRef.current = ws wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
setStatus('connected') setStatus('connected');
reconnectAttemptsRef.current = 0 reconnectAttemptsRef.current = 0;
onConnect?.() onConnect?.();
} };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data) as WsOutbound const message = JSON.parse(event.data) as WsOutbound;
onMessage?.(message) onMessage?.(message);
} catch (error) { } catch (error) {
console.error('Failed to parse message:', error) console.error('Failed to parse message:', error);
} }
} };
ws.onerror = (error) => { ws.onerror = (error) => {
setStatus('error') setStatus('error');
onError?.(error) onError?.(error);
} };
ws.onclose = () => { ws.onclose = () => {
setStatus('disconnected') setStatus('disconnected');
onDisconnect?.() onDisconnect?.();
// Auto reconnect if not manually disconnected // Auto reconnect if not manually disconnected
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
reconnectAttemptsRef.current += 1 reconnectAttemptsRef.current += 1;
reconnectTimerRef.current = setTimeout(() => { reconnectTimerRef.current = setTimeout(() => {
connect() connect();
}, reconnectInterval) }, reconnectInterval);
} }
} };
} catch (error) { } catch (error) {
setStatus('error') setStatus('error');
console.error('WebSocket connection error:', error) console.error('WebSocket connection error:', error);
} }
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts]) }, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts]);
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
isManualDisconnectRef.current = true isManualDisconnectRef.current = true;
if (reconnectTimerRef.current) { if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null reconnectTimerRef.current = null;
} }
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close() wsRef.current.close();
wsRef.current = null wsRef.current = null;
} }
setStatus('disconnected') setStatus('disconnected');
}, []) }, []);
const sendMessage = useCallback((message: WsInbound): boolean => { const sendMessage = useCallback((message: WsInbound): boolean => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message)) wsRef.current.send(JSON.stringify(message));
return true return true;
} }
console.warn('WebSocket is not connected') console.warn('WebSocket is not connected');
return false return false;
}, []) }, []);
// Auto connect on mount, reconnect when url changes // Auto connect on mount, reconnect when url changes
const prevUrlRef = useRef(url) const prevUrlRef = useRef(url);
useEffect(() => { useEffect(() => {
// 首次挂载,或者 url 发生了变化,都要重连 // 首次挂载,或者 url 发生了变化,都要重连
if (prevUrlRef.current !== url) { if (prevUrlRef.current !== url) {
// 先断开旧连接 // 先断开旧连接
disconnect() disconnect();
prevUrlRef.current = url prevUrlRef.current = url;
} }
// 短暂延迟确保旧 socket 已关闭 // 短暂延迟确保旧 socket 已关闭
const timer = setTimeout(() => { const timer = setTimeout(() => {
connect() connect();
}, 50) }, 50);
return () => { return () => {
clearTimeout(timer) clearTimeout(timer);
disconnect() disconnect();
} };
}, [url, connect, disconnect]) }, [url, connect, disconnect]);
return { return {
status, status,
sendMessage, sendMessage,
connect, connect,
disconnect, disconnect,
} };
} }

View File

@ -1,4 +1,4 @@
@import "tailwindcss"; @import 'tailwindcss';
/* ============================================ /* ============================================
PicoBot Theme Dark (default) PicoBot Theme Dark (default)
@ -28,28 +28,28 @@
--border-accent: rgba(0, 240, 255, 0.3); --border-accent: rgba(0, 240, 255, 0.3);
/* Overlay — for hover / subtle surface effects */ /* Overlay — for hover / subtle surface effects */
--overlay-hover: rgba(255, 255, 255, 0.05); --overlay-hover: rgba(255, 255, 255, 0.05);
--overlay-subtle: rgba(255, 255, 255, 0.10); --overlay-subtle: rgba(255, 255, 255, 0.1);
--overlay-medium: rgba(255, 255, 255, 0.20); --overlay-medium: rgba(255, 255, 255, 0.2);
--overlay-dim: rgba(0, 0, 0, 0.20); --overlay-dim: rgba(0, 0, 0, 0.2);
--overlay-dim-strong: rgba(0, 0, 0, 0.30); --overlay-dim-strong: rgba(0, 0, 0, 0.3);
--overlay-dim-heavy: rgba(0, 0, 0, 0.40); --overlay-dim-heavy: rgba(0, 0, 0, 0.4);
--overlay-code: rgba(0, 0, 0, 0.30); --overlay-code: rgba(0, 0, 0, 0.3);
/* Shadows */ /* Shadows */
--shadow-glow-sm: rgba(0, 240, 255, 0.12); --shadow-glow-sm: rgba(0, 240, 255, 0.12);
--shadow-glow: rgba(0, 240, 255, 0.20); --shadow-glow: rgba(0, 240, 255, 0.2);
--shadow-glow-strong: rgba(0, 240, 255, 0.30); --shadow-glow-strong: rgba(0, 240, 255, 0.3);
--shadow-glow-soft: rgba(0, 240, 255, 0.50); --shadow-glow-soft: rgba(0, 240, 255, 0.5);
/* Focus ring */ /* Focus ring */
--focus-ring: rgba(0, 240, 255, 0.20); --focus-ring: rgba(0, 240, 255, 0.2);
/* Selection / code highlight */ /* Selection / code highlight */
--selection-bg: rgba(0, 240, 255, 0.30); --selection-bg: rgba(0, 240, 255, 0.3);
/* Divider */ /* Divider */
--divider-color: rgba(255, 255, 255, 0.20); --divider-color: rgba(255, 255, 255, 0.2);
color-scheme: dark; color-scheme: dark;
} }
@ -59,51 +59,51 @@
============================================ */ ============================================ */
html.light { html.light {
/* Backgrounds */ /* Backgrounds */
--bg-primary: #f5f5f7; --bg-primary: #f5f5f7;
--bg-secondary: #ffffff; --bg-secondary: #ffffff;
--bg-tertiary: #ebecf0; --bg-tertiary: #ebecf0;
--bg-hover: #dfe0e5; --bg-hover: #dfe0e5;
/* Accents — slightly darker for readability on white */ /* Accents — slightly darker for readability on white */
--accent-cyan: #008899; --accent-cyan: #008899;
--accent-blue: #2563eb; --accent-blue: #2563eb;
--accent-purple: #7c3aed; --accent-purple: #7c3aed;
--accent-green: #059669; --accent-green: #059669;
--accent-amber: #d97706; --accent-amber: #d97706;
/* Text */ /* Text */
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
--text-secondary: #52525b; --text-secondary: #52525b;
--text-muted: #a1a1aa; --text-muted: #a1a1aa;
--text-accent: var(--accent-cyan); --text-accent: var(--accent-cyan);
/* Borders */ /* Borders */
--border-color: rgba(0, 0, 0, 0.08); --border-color: rgba(0, 0, 0, 0.08);
--border-accent: rgba(0, 136, 153, 0.30); --border-accent: rgba(0, 136, 153, 0.3);
/* Overlays */ /* Overlays */
--overlay-hover: rgba(0, 0, 0, 0.04); --overlay-hover: rgba(0, 0, 0, 0.04);
--overlay-subtle: rgba(0, 0, 0, 0.06); --overlay-subtle: rgba(0, 0, 0, 0.06);
--overlay-medium: rgba(0, 0, 0, 0.10); --overlay-medium: rgba(0, 0, 0, 0.1);
--overlay-dim: rgba(0, 0, 0, 0.04); --overlay-dim: rgba(0, 0, 0, 0.04);
--overlay-dim-strong: rgba(0, 0, 0, 0.06); --overlay-dim-strong: rgba(0, 0, 0, 0.06);
--overlay-dim-heavy: rgba(0, 0, 0, 0.08); --overlay-dim-heavy: rgba(0, 0, 0, 0.08);
--overlay-code: rgba(0, 0, 0, 0.06); --overlay-code: rgba(0, 0, 0, 0.06);
/* Shadows — softer */ /* Shadows — softer */
--shadow-glow-sm: rgba(0, 136, 153, 0.10); --shadow-glow-sm: rgba(0, 136, 153, 0.1);
--shadow-glow: rgba(0, 136, 153, 0.15); --shadow-glow: rgba(0, 136, 153, 0.15);
--shadow-glow-strong: rgba(0, 136, 153, 0.22); --shadow-glow-strong: rgba(0, 136, 153, 0.22);
--shadow-glow-soft: rgba(0, 136, 153, 0.35); --shadow-glow-soft: rgba(0, 136, 153, 0.35);
/* Focus ring */ /* Focus ring */
--focus-ring: rgba(0, 136, 153, 0.20); --focus-ring: rgba(0, 136, 153, 0.2);
/* Selection / code highlight */ /* Selection / code highlight */
--selection-bg: rgba(0, 136, 153, 0.20); --selection-bg: rgba(0, 136, 153, 0.2);
/* Divider */ /* Divider */
--divider-color: rgba(0, 0, 0, 0.10); --divider-color: rgba(0, 0, 0, 0.1);
color-scheme: light; color-scheme: light;
} }
@ -114,11 +114,12 @@ html.light {
html.theme-transitioning *, html.theme-transitioning *,
html.theme-transitioning *::before, html.theme-transitioning *::before,
html.theme-transitioning *::after { html.theme-transitioning *::after {
transition: background-color 0.3s ease, transition:
color 0.3s ease, background-color 0.3s ease,
border-color 0.3s ease, color 0.3s ease,
box-shadow 0.3s ease, border-color 0.3s ease,
text-shadow 0.3s ease !important; box-shadow 0.3s ease,
text-shadow 0.3s ease !important;
} }
/* ============================================ /* ============================================
@ -182,9 +183,10 @@ body {
Glowing text effect Glowing text effect
============================================ */ ============================================ */
.glow-text { .glow-text {
text-shadow: 0 0 10px var(--shadow-glow-soft), text-shadow:
0 0 20px var(--shadow-glow-strong), 0 0 10px var(--shadow-glow-soft),
0 0 30px var(--shadow-glow-sm); 0 0 20px var(--shadow-glow-strong),
0 0 30px var(--shadow-glow-sm);
} }
/* ============================================ /* ============================================
@ -215,15 +217,18 @@ body {
Animations Animations
============================================ */ ============================================ */
@keyframes pulse-glow { @keyframes pulse-glow {
0%, 100% { 0%,
box-shadow: 0 0 5px var(--accent-cyan), 100% {
0 0 10px var(--accent-cyan), box-shadow:
0 0 20px var(--accent-cyan); 0 0 5px var(--accent-cyan),
0 0 10px var(--accent-cyan),
0 0 20px var(--accent-cyan);
} }
50% { 50% {
box-shadow: 0 0 10px var(--accent-cyan), box-shadow:
0 0 20px var(--accent-cyan), 0 0 10px var(--accent-cyan),
0 0 40px var(--accent-cyan); 0 0 20px var(--accent-cyan),
0 0 40px var(--accent-cyan);
} }
} }
@ -239,24 +244,48 @@ body {
} }
@keyframes fade-in { @keyframes fade-in {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes typing-dot { @keyframes typing-dot {
0%, 60%, 100% { transform: translateY(0); } 0%,
30% { transform: translateY(-4px); } 60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-4px);
}
} }
@keyframes scale-in { @keyframes scale-in {
0% { opacity: 0; transform: scale(0.5); } 0% {
50% { transform: scale(1.1); } opacity: 0;
100% { opacity: 1; transform: scale(1); } transform: scale(0.5);
}
50% {
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
.animate-slide-in { animation: slide-in 0.3s ease-out; } .animate-slide-in {
.animate-fade-in { animation: fade-in 0.2s ease-out; } animation: slide-in 0.3s ease-out;
.animate-scale-in { animation: scale-in 0.3s ease-out; } }
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.3s ease-out;
}
/* ============================================ /* ============================================
TodoPanel animations TodoPanel animations
@ -286,7 +315,8 @@ body {
} }
@keyframes todo-ring-pulse { @keyframes todo-ring-pulse {
0%, 100% { 0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
} }
50% { 50% {
@ -295,7 +325,8 @@ body {
} }
@keyframes todo-highlight-pulse { @keyframes todo-highlight-pulse {
0%, 100% { 0%,
100% {
background-color: transparent; background-color: transparent;
} }
50% { 50% {
@ -303,9 +334,15 @@ body {
} }
} }
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } .animate-todo-card-in {
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; } animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; } }
.animate-todo-item-in {
animation: todo-item-in 0.2s ease-out forwards;
}
.animate-todo-ring-pulse {
animation: todo-ring-pulse 2s ease-in-out infinite;
}
/* 待办点击高亮效果 */ /* 待办点击高亮效果 */
.todo-highlight { .todo-highlight {
@ -316,7 +353,9 @@ body {
/* 分组折叠内容展开/收起 */ /* 分组折叠内容展开/收起 */
.todo-group-body { .todo-group-body {
overflow: hidden; overflow: hidden;
transition: max-height 0.25s ease, opacity 0.2s ease; transition:
max-height 0.25s ease,
opacity 0.2s ease;
} }
.todo-group-body-open { .todo-group-body-open {
max-height: 600px; max-height: 600px;
@ -328,18 +367,30 @@ body {
} }
@keyframes thinking-reveal { @keyframes thinking-reveal {
from { max-height: 0; opacity: 0; } from {
to { max-height: 300px; opacity: 1; } max-height: 0;
opacity: 0;
}
to {
max-height: 300px;
opacity: 1;
}
} }
.animate-thinking-reveal { animation: thinking-reveal 0.25s ease-out; } .animate-thinking-reveal {
animation: thinking-reveal 0.25s ease-out;
}
.typing-indicator span { .typing-indicator span {
animation: typing-dot 1.4s infinite; animation: typing-dot 1.4s infinite;
display: inline-block; display: inline-block;
} }
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } .typing-indicator span:nth-child(2) {
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
/* ============================================ /* ============================================
Markdown list kill extra spacing from loose-list <p> wrappers Markdown list kill extra spacing from loose-list <p> wrappers

View File

@ -1,10 +1,10 @@
import React from 'react' import React from 'react';
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client';
import App from './App' import App from './App';
import './index.css' import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />
</React.StrictMode>, </React.StrictMode>,
) );

View File

@ -1 +1 @@
import '@testing-library/jest-dom/vitest' import '@testing-library/jest-dom/vitest';

View File

@ -5,294 +5,294 @@
// ============================================================================ // ============================================================================
export interface WsInboundMessage { export interface WsInboundMessage {
type: 'message' type: 'message';
content: string content: string;
attachments?: Attachment[] attachments?: Attachment[];
channel?: string channel?: string;
chat_id?: string chat_id?: string;
sender_id?: string sender_id?: string;
} }
export interface WsInboundCommand { export interface WsInboundCommand {
type: 'command' type: 'command';
payload: string payload: string;
} }
export interface WsInboundPing { export interface WsInboundPing {
type: 'ping' type: 'ping';
} }
export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing;
// ============================================================================ // ============================================================================
// Outbound Messages (Server -> Client) // Outbound Messages (Server -> Client)
// ============================================================================ // ============================================================================
export interface Attachment { export interface Attachment {
path: string path: string;
media_type: string media_type: string;
mime_type?: string mime_type?: string;
content_base64?: string content_base64?: string;
file_name?: string file_name?: string;
} }
export interface AssistantResponse { export interface AssistantResponse {
type: 'assistant_response' type: 'assistant_response';
id: string id: string;
content: string content: string;
role: string role: string;
attachments?: Attachment[] attachments?: Attachment[];
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
timestamp?: number timestamp?: number;
reasoning_content?: string reasoning_content?: string;
user_message_id?: string user_message_id?: string;
} }
export interface ToolCall { export interface ToolCall {
type: 'tool_call' type: 'tool_call';
id: string id: string;
tool_call_id: string tool_call_id: string;
tool_name: string tool_name: string;
arguments: unknown arguments: unknown;
content: string content: string;
role: string role: string;
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
timestamp?: number timestamp?: number;
reasoning_content?: string reasoning_content?: string;
user_message_id?: string user_message_id?: string;
} }
export interface ToolResult { export interface ToolResult {
type: 'tool_result' type: 'tool_result';
id: string id: string;
tool_call_id: string tool_call_id: string;
tool_name: string tool_name: string;
content: string content: string;
role: string role: string;
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
duration_ms?: number duration_ms?: number;
timestamp?: number timestamp?: number;
} }
export interface ToolPending { export interface ToolPending {
type: 'tool_pending' type: 'tool_pending';
id: string id: string;
tool_call_id: string tool_call_id: string;
tool_name: string tool_name: string;
content: string content: string;
role: string role: string;
resume_hint: string resume_hint: string;
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
timestamp?: number timestamp?: number;
} }
export interface WsError { export interface WsError {
type: 'error' type: 'error';
code: string code: string;
message: string message: string;
timestamp?: number timestamp?: number;
subagent_task_id?: string subagent_task_id?: string;
} }
export interface TaskStarted { export interface TaskStarted {
type: 'task_started' type: 'task_started';
task_id: string task_id: string;
description: string description: string;
subagent_type: string subagent_type: string;
topic_id?: string topic_id?: string;
parent_task_id?: string parent_task_id?: string;
tool_call_id?: string tool_call_id?: string;
} }
export interface SessionEstablished { export interface SessionEstablished {
type: 'session_established' type: 'session_established';
session_id: string session_id: string;
} }
export interface SessionCreated { export interface SessionCreated {
type: 'session_created' type: 'session_created';
session_id: string session_id: string;
title: string title: string;
} }
export interface SessionSummary { export interface SessionSummary {
session_id: string session_id: string;
title: string title: string;
channel_name: string channel_name: string;
chat_id: string chat_id: string;
message_count: number message_count: number;
last_active_at: number last_active_at: number;
archived_at?: number archived_at?: number;
} }
export interface SessionList { export interface SessionList {
type: 'session_list' type: 'session_list';
sessions: SessionSummary[] sessions: SessionSummary[];
current_session_id?: string current_session_id?: string;
channel_name?: string // 新增:标识所属通道 channel_name?: string; // 新增:标识所属通道
} }
export interface SessionLoaded { export interface SessionLoaded {
type: 'session_loaded' type: 'session_loaded';
session_id: string session_id: string;
title: string title: string;
message_count: number message_count: number;
} }
export interface SessionSaved { export interface SessionSaved {
type: 'session_saved' type: 'session_saved';
session_id: string session_id: string;
filepath: string filepath: string;
} }
export interface TopicSummary { export interface TopicSummary {
topic_id: string topic_id: string;
session_id: string session_id: string;
title: string title: string;
description?: string description?: string;
message_count: number message_count: number;
created_at: number created_at: number;
last_active_at: number last_active_at: number;
} }
export interface TopicList { export interface TopicList {
type: 'topic_list' type: 'topic_list';
topics: TopicSummary[] topics: TopicSummary[];
session_id: string session_id: string;
} }
export interface TopicRenamed { export interface TopicRenamed {
type: 'topic_renamed' type: 'topic_renamed';
topic_id: string topic_id: string;
title: string title: string;
topics: TopicSummary[] topics: TopicSummary[];
session_id: string session_id: string;
} }
export interface Channel { export interface Channel {
id: string id: string;
name: string name: string;
description?: string description?: string;
isWritable: boolean isWritable: boolean;
} }
export interface ChannelList { export interface ChannelList {
type: 'channel_list' type: 'channel_list';
channels: Channel[] channels: Channel[];
} }
export interface Pong { export interface Pong {
type: 'pong' type: 'pong';
} }
export interface MemorySummary { export interface MemorySummary {
id: string id: string;
namespace: string namespace: string;
memory_key: string memory_key: string;
content: string content: string;
created_at: number created_at: number;
updated_at: number updated_at: number;
} }
export interface MemoryList { export interface MemoryList {
type: 'memory_list' type: 'memory_list';
memories: MemorySummary[] memories: MemorySummary[];
} }
export interface SkillSummary { export interface SkillSummary {
name: string name: string;
description: string description: string;
source: string source: string;
} }
export interface SkillList { export interface SkillList {
type: 'skill_list' type: 'skill_list';
skills: SkillSummary[] skills: SkillSummary[];
} }
export interface TodoItemSummary { export interface TodoItemSummary {
id: string id: string;
content: string content: string;
status: string status: string;
priority: string priority: string;
created_at: number created_at: number;
updated_at: number updated_at: number;
created_by_message_id?: string created_by_message_id?: string;
} }
export interface TodoList { export interface TodoList {
type: 'todo_list' type: 'todo_list';
todos: TodoItemSummary[] todos: TodoItemSummary[];
scope_key: string scope_key: string;
} }
export interface SchedulerJobSessionLookup { export interface SchedulerJobSessionLookup {
channel: string channel: string;
chat_id: string chat_id: string;
} }
export interface SchedulerJobSummary { export interface SchedulerJobSummary {
id: string id: string;
kind: string kind: string;
schedule: unknown schedule: unknown;
enabled: boolean enabled: boolean;
state: string state: string;
last_status?: string last_status?: string;
last_error?: string last_error?: string;
run_count: number run_count: number;
max_runs?: number max_runs?: number;
last_fired_at?: number last_fired_at?: number;
next_fire_at?: number next_fire_at?: number;
created_at: number created_at: number;
session_lookup?: SchedulerJobSessionLookup session_lookup?: SchedulerJobSessionLookup;
} }
export interface SchedulerJobList { export interface SchedulerJobList {
type: 'scheduler_job_list' type: 'scheduler_job_list';
jobs: SchedulerJobSummary[] jobs: SchedulerJobSummary[];
} }
export interface TaskMessagesLoaded { export interface TaskMessagesLoaded {
type: 'task_messages_loaded' type: 'task_messages_loaded';
task_id: string task_id: string;
description: string description: string;
subagent_type: string subagent_type: string;
status: string status: string;
summary?: string summary?: string;
} }
export interface ExecutionCancelled { export interface ExecutionCancelled {
type: 'execution_cancelled' type: 'execution_cancelled';
message: string message: string;
timestamp?: number timestamp?: number;
} }
export interface StreamDelta { export interface StreamDelta {
type: 'stream_delta' type: 'stream_delta';
id: string id: string;
delta: string delta: string;
reasoning_delta?: string reasoning_delta?: string;
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
user_message_id?: string user_message_id?: string;
} }
export interface StreamEnd { export interface StreamEnd {
type: 'stream_end' type: 'stream_end';
id: string id: string;
subagent_task_id?: string subagent_task_id?: string;
topic_id?: string topic_id?: string;
} }
export interface ExecutionCompleted { export interface ExecutionCompleted {
type: 'execution_completed' type: 'execution_completed';
topic_id?: string topic_id?: string;
timestamp?: number timestamp?: number;
subagent_task_id?: string subagent_task_id?: string;
} }
export type WsOutbound = export type WsOutbound =
@ -319,127 +319,127 @@ export type WsOutbound =
| SkillList | SkillList
| TodoList | TodoList
| ExecutionCancelled | ExecutionCancelled
| Pong | Pong;
// ============================================================================ // ============================================================================
// Commands // Commands
// ============================================================================ // ============================================================================
export interface CreateSessionCommand { export interface CreateSessionCommand {
type: 'create_session' type: 'create_session';
title?: string title?: string;
} }
export interface ListSessionsCommand { export interface ListSessionsCommand {
type: 'list_sessions' type: 'list_sessions';
include_archived: boolean include_archived: boolean;
} }
export interface SwitchTopicCommand { export interface SwitchTopicCommand {
type: 'switch_topic' type: 'switch_topic';
topic_id: string topic_id: string;
} }
export interface SaveTopicCommand { export interface SaveTopicCommand {
type: 'save_topic' type: 'save_topic';
filepath?: string filepath?: string;
include_subagents: boolean include_subagents: boolean;
} }
export interface SaveSessionCommand { export interface SaveSessionCommand {
type: 'save_session' type: 'save_session';
filepath?: string filepath?: string;
include_all: boolean include_all: boolean;
include_subagents: boolean include_subagents: boolean;
} }
export interface LoadTopicCommand { export interface LoadTopicCommand {
type: 'load_topic' type: 'load_topic';
topic_id: string topic_id: string;
} }
export interface GetCurrentSessionCommand { export interface GetCurrentSessionCommand {
type: 'get_current_session' type: 'get_current_session';
} }
export interface HelpCommand { export interface HelpCommand {
type: 'help' type: 'help';
} }
export interface ListChannelsCommand { export interface ListChannelsCommand {
type: 'list_channels' type: 'list_channels';
} }
export interface ListSessionsByChannelCommand { export interface ListSessionsByChannelCommand {
type: 'list_sessions_by_channel' type: 'list_sessions_by_channel';
channel_name: string channel_name: string;
include_archived: boolean include_archived: boolean;
} }
export interface ListTopicsCommand { export interface ListTopicsCommand {
type: 'list_topics' type: 'list_topics';
session_id: string session_id: string;
} }
export interface LoadTaskMessagesCommand { export interface LoadTaskMessagesCommand {
type: 'load_task_messages' type: 'load_task_messages';
task_id: string task_id: string;
} }
export interface ListSchedulerJobsCommand { export interface ListSchedulerJobsCommand {
type: 'list_scheduler_jobs' type: 'list_scheduler_jobs';
} }
export interface LoadChatMessagesCommand { export interface LoadChatMessagesCommand {
type: 'load_chat_messages' type: 'load_chat_messages';
channel: string channel: string;
chat_id: string chat_id: string;
} }
export interface DeleteTopicCommand { export interface DeleteTopicCommand {
type: 'delete_topic' type: 'delete_topic';
topic_id: string topic_id: string;
} }
export interface RenameTopicCommand { export interface RenameTopicCommand {
type: 'rename_topic' type: 'rename_topic';
topic_id: string topic_id: string;
title: string title: string;
} }
export interface StopExecutionCommand { export interface StopExecutionCommand {
type: 'stop_execution' type: 'stop_execution';
} }
export interface ListMemoriesCommand { export interface ListMemoriesCommand {
type: 'list_memories' type: 'list_memories';
} }
export interface CreateMemoryCommand { export interface CreateMemoryCommand {
type: 'create_memory' type: 'create_memory';
namespace: string namespace: string;
key: string key: string;
content: string content: string;
} }
export interface UpdateMemoryCommand { export interface UpdateMemoryCommand {
type: 'update_memory' type: 'update_memory';
id: string id: string;
content: string content: string;
} }
export interface DeleteMemoryCommand { export interface DeleteMemoryCommand {
type: 'delete_memory' type: 'delete_memory';
id: string id: string;
} }
export interface ListSkillsCommand { export interface ListSkillsCommand {
type: 'list_skills' type: 'list_skills';
} }
export interface ListTodosCommand { export interface ListTodosCommand {
type: 'list_todos' type: 'list_todos';
task_id?: string task_id?: string;
} }
export type Command = export type Command =
@ -465,57 +465,57 @@ export type Command =
| UpdateMemoryCommand | UpdateMemoryCommand
| DeleteMemoryCommand | DeleteMemoryCommand
| ListSkillsCommand | ListSkillsCommand
| ListTodosCommand | ListTodosCommand;
// ============================================================================ // ============================================================================
// UI Types // UI Types
// ============================================================================ // ============================================================================
export interface ChatMessage { export interface ChatMessage {
id: string id: string;
role: 'user' | 'assistant' | 'tool' role: 'user' | 'assistant' | 'tool';
content: string content: string;
timestamp: number timestamp: number;
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool' type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool';
toolName?: string toolName?: string;
toolCallId?: string toolCallId?: string;
arguments?: unknown arguments?: unknown;
attachments?: Attachment[] attachments?: Attachment[];
status?: 'calling' | 'result' | 'pending' status?: 'calling' | 'result' | 'pending';
resultContent?: string resultContent?: string;
callContent?: string callContent?: string;
/** 路由字段:标识消息属于哪个子智能体会话(与后端 subagent_task_id 一致) */ /** 路由字段:标识消息属于哪个子智能体会话(与后端 subagent_task_id 一致) */
subagentTaskId?: string subagentTaskId?: string;
/** 导航字段:仅 task 工具卡片使用,由 task_started 事件设置,指向新创建的子/孙智能体 task_id */ /** 导航字段:仅 task 工具卡片使用,由 task_started 事件设置,指向新创建的子/孙智能体 task_id */
navigateToTaskId?: string navigateToTaskId?: string;
durationMs?: number durationMs?: number;
reasoningContent?: string reasoningContent?: string;
} }
/** task 工具返回的 JSON 结构 */ /** task 工具返回的 JSON 结构 */
export interface TaskToolResult { export interface TaskToolResult {
status: 'success' | 'failed' | 'timeout' status: 'success' | 'failed' | 'timeout';
summary: string summary: string;
output: string output: string;
task_id: string task_id: string;
} }
export interface Topic { export interface Topic {
id: string id: string;
session_id: string session_id: string;
title: string title: string;
description?: string description?: string;
message_count: number message_count: number;
created_at: number created_at: number;
updated_at: number updated_at: number;
} }
export interface Session { export interface Session {
id: string id: string;
channel_name: string channel_name: string;
title?: string title?: string;
created_at: number created_at: number;
updated_at: number updated_at: number;
} }
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error' export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';