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
run: npm run lint
- name: Format check (prettier)
working-directory: web
run: npm run format:check
- name: Type check
working-directory: web
run: npx tsc --noEmit

View File

@ -49,8 +49,9 @@ clean:
check:
@echo "Checking formatting..."
cargo fmt --all -- --check
@echo "Checking frontend (lint + build)..."
@echo "Checking frontend (lint + format + build)..."
cd web && npm run lint
cd web && npm run format:check
cd web && npm run build
@echo "Checking Rust code..."
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',
sessionSelectModel: '/api/session/select-model',
sessionSelectedModel: '/api/session/selected-model',
} as const
} as const;
/**
* fetch JSON headers
@ -28,7 +28,7 @@ export const API = {
*/
export async function apiFetch<T>(
endpoint: string,
options?: { method?: string; body?: unknown; signal?: AbortSignal }
options?: { method?: string; body?: unknown; signal?: AbortSignal },
): Promise<[T | null, { status: number; message: string } | null]> {
try {
const resp = await fetch(endpoint, {
@ -36,14 +36,17 @@ export async function apiFetch<T>(
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
body: options?.body ? JSON.stringify(options.body) : undefined,
signal: options?.signal,
})
const data = await resp.json().catch(() => null)
});
const data = await resp.json().catch(() => null);
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) {
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> {
try {
const resp = await fetch(endpoint)
if (!resp.ok) return null
return await resp.json() as T
const resp = await fetch(endpoint);
if (!resp.ok) return null;
return (await resp.json()) as T;
} catch {
return null
return null;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,145 +1,150 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'
import { useState, useEffect, useRef, useCallback } from 'react';
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
interface ExpertItem {
name: string
description: string
source: string
path?: string
body?: string
disabled_in_scopes: string[]
name: string;
description: string;
source: string;
path?: string;
body?: string;
disabled_in_scopes: string[];
}
interface SelectedExpert {
name: string
description: string
name: string;
description: string;
}
interface ExpertSelectorProps {
sessionId: string | null
onManageExperts?: () => void
onSelectionChange?: (expert: SelectedExpert | null) => void
sessionId: string | null;
onManageExperts?: () => void;
onSelectionChange?: (expert: SelectedExpert | null) => void;
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
settingsClosedTick?: number
settingsClosedTick?: number;
}
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) {
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)
export function ExpertSelector({
sessionId,
onManageExperts,
onSelectionChange,
settingsClosedTick,
}: ExpertSelectorProps) {
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
const refreshSelection = useCallback(() => {
if (!sessionId) {
setSelectedExpert(null)
onSelectionChange?.(null)
return
setSelectedExpert(null);
onSelectionChange?.(null);
return;
}
setLoading(true)
setError(null)
setLoading(true);
setError(null);
getSelectedExpert(sessionId)
.then(data => {
.then((data) => {
if (data?.expert) {
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
onSelectionChange?.({ 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 });
} else {
// 已选专家被禁用/删除时,后端返回 null前端同步清除
setSelectedExpert(null)
onSelectionChange?.(null)
setSelectedExpert(null);
onSelectionChange?.(null);
}
})
.catch(() => {
// Silent fail: default to no expert
setSelectedExpert(null)
onSelectionChange?.(null)
setSelectedExpert(null);
onSelectionChange?.(null);
})
.finally(() => setLoading(false))
}, [sessionId, onSelectionChange])
.finally(() => setLoading(false));
}, [sessionId, onSelectionChange]);
// Load current selection whenever sessionId changes
useEffect(() => {
refreshSelection()
}, [refreshSelection])
refreshSelection();
}, [refreshSelection]);
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
useEffect(() => {
if (settingsClosedTick === undefined) return
refreshSelection()
if (settingsClosedTick === undefined) return;
refreshSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick])
}, [settingsClosedTick]);
// Click outside to close dropdown
useEffect(() => {
if (!open) return
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setOpen(false);
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const fetchExpertList = useCallback(async () => {
setListLoading(true)
const data = await listExperts()
setListLoading(true);
const data = await listExperts();
if (data) {
// Only show enabled experts (disabled_in_scopes.length === 0)
const enabled = (data.experts ?? []).filter(
(e: ExpertItem) => e.disabled_in_scopes.length === 0
)
setExpertList(enabled)
(e: ExpertItem) => e.disabled_in_scopes.length === 0,
);
setExpertList(enabled);
}
setListLoading(false)
}, [])
setListLoading(false);
}, []);
const handleToggleOpen = () => {
const next = !open
setOpen(next)
const next = !open;
setOpen(next);
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
if (next) {
fetchExpertList()
refreshSelection()
fetchExpertList();
refreshSelection();
}
}
};
const handleSelect = async (expert: SelectedExpert | null) => {
if (!sessionId) return
if (!sessionId) return;
// Optimistic update
const prev = selectedExpert
setSelectedExpert(expert)
onSelectionChange?.(expert)
setOpen(false)
const prev = selectedExpert;
setSelectedExpert(expert);
onSelectionChange?.(expert);
setOpen(false);
try {
const result = await selectExpert(sessionId, expert?.name ?? null)
const result = await selectExpert(sessionId, expert?.name ?? null);
if (!result.success) {
// Revert
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError(result.error || '切换专家失败')
setTimeout(() => setError(null), 3000)
setSelectedExpert(prev);
onSelectionChange?.(prev);
setError(result.error || '切换专家失败');
setTimeout(() => setError(null), 3000);
}
} catch {
setSelectedExpert(prev)
onSelectionChange?.(prev)
setError('网络错误,切换专家失败')
setTimeout(() => setError(null), 3000)
setSelectedExpert(prev);
onSelectionChange?.(prev);
setError('网络错误,切换专家失败');
setTimeout(() => setError(null), 3000);
}
}
};
const handleManage = () => {
setOpen(false)
onManageExperts?.()
}
setOpen(false);
onManageExperts?.();
};
// If sessionId is null, render nothing
if (!sessionId) return null
if (!sessionId) return null;
return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
@ -148,7 +153,9 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
onClick={handleToggleOpen}
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"
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
title={
selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'
}
>
{loading ? (
<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>
{open && (
<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"
>
<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">
{listLoading && expertList.length === 0 ? (
<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" /> ...
@ -197,12 +202,14 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
</button>
{expertList.length > 0 ? (
<div className="border-t border-[var(--border-color)]">
{expertList.map(expert => {
const isSelected = selectedExpert?.name === expert.name
{expertList.map((expert) => {
const isSelected = selectedExpert?.name === expert.name;
return (
<button
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"
>
<UserCheck
@ -230,7 +237,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
)}
</div>
</button>
)
);
})}
</div>
) : (
@ -249,9 +256,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
</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 { useState, useRef, useEffect } from 'react'
import type { Attachment } from '../../types/protocol'
import {
Send,
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 {
onSend: (content: string, attachments: Attachment[]) => void
onStop?: () => void
disabled?: boolean
isLoading?: boolean
placeholder?: string
isReadOnly?: boolean
channelName?: string
selectedExpert?: { name: string; description: string } | null
onSend: (content: string, attachments: Attachment[]) => void;
onStop?: () => void;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
isReadOnly?: boolean;
channelName?: string;
selectedExpert?: { name: string; description: string } | null;
}
interface FileAttachment {
id: string
file: File
attachment: Attachment
preview?: string // 用于图片预览
id: string;
file: File;
attachment: Attachment;
preview?: string; // 用于图片预览
}
// 根据 MIME 类型判断 media_type
function getMediaType(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'image'
if (mimeType.startsWith('audio/')) return 'audio'
if (mimeType.startsWith('video/')) return 'video'
return 'file'
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType.startsWith('video/')) return 'video';
return 'file';
}
export function MessageInput({
@ -40,49 +52,50 @@ export function MessageInput({
channelName,
selectedExpert,
}: MessageInputProps) {
const effectivePlaceholder = placeholder
?? (selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令')
const [content, setContent] = useState('')
const [attachments, setAttachments] = useState<FileAttachment[]>([])
const [isDragging, setIsDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const wasLoadingRef = useRef(false)
const effectivePlaceholder =
placeholder ??
(selectedExpert ? `${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令');
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const wasLoadingRef = useRef(false);
useEffect(() => {
const textarea = textareaRef.current
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, [content])
}, [content]);
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
useEffect(() => {
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
textareaRef.current?.focus()
textareaRef.current?.focus();
}
wasLoadingRef.current = isLoading
}, [isLoading, isReadOnly])
wasLoadingRef.current = isLoading;
}, [isLoading, isReadOnly]);
// 处理文件选择
const handleFileSelect = async (files: FileList | null) => {
if (!files) return
setError(null)
if (!files) return;
setError(null);
const newAttachments: FileAttachment[] = []
const newAttachments: FileAttachment[] = [];
for (const file of Array.from(files)) {
// 检查文件大小
if (file.size > MAX_FILE_SIZE) {
setError(`文件 "${file.name}" 超过 50MB 限制`)
continue
setError(`文件 "${file.name}" 超过 50MB 限制`);
continue;
}
// 读取文件为 base64
const base64 = await readFileAsBase64(file)
const mimeType = file.type || 'application/octet-stream'
const mediaType = getMediaType(mimeType)
const base64 = await readFileAsBase64(file);
const mimeType = file.type || 'application/octet-stream';
const mediaType = getMediaType(mimeType);
const attachment: Attachment = {
path: file.name,
@ -90,78 +103,78 @@ export function MessageInput({
mime_type: mimeType,
content_base64: base64,
file_name: file.name,
}
};
const fileAttachment: FileAttachment = {
id: crypto.randomUUID(),
file,
attachment,
preview: mediaType === 'image' ? base64 : undefined,
}
};
newAttachments.push(fileAttachment)
newAttachments.push(fileAttachment);
}
setAttachments(prev => [...prev, ...newAttachments])
}
setAttachments((prev) => [...prev, ...newAttachments]);
};
// 读取文件为 base64
const readFileAsBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string
const result = reader.result as string;
// 移除 data:xxx;base64, 前缀
const base64 = result.split(',')[1]
resolve(base64)
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
const base64 = result.split(',')[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
// 点击附件按钮
const handleAttachClick = () => {
fileInputRef.current?.click()
}
fileInputRef.current?.click();
};
// 删除附件
const handleRemoveAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index))
}
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
// 粘贴事件处理
const handlePaste = async (e: React.ClipboardEvent) => {
if (disabled || isReadOnly) return
if (disabled || isReadOnly) return;
const clipboardData = e.clipboardData
const items = clipboardData.items
const clipboardData = e.clipboardData;
const items = clipboardData.items;
// 检查是否有文件(图片或其他文件)
const files: File[] = []
const files: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile()
const file = item.getAsFile();
if (file) {
files.push(file)
files.push(file);
}
}
}
// 如果有文件,处理文件并阻止默认粘贴行为
if (files.length > 0) {
e.preventDefault()
e.preventDefault();
// 直接处理文件数组
setError(null)
setError(null);
for (const file of files) {
if (file.size > MAX_FILE_SIZE) {
setError(`文件 "${file.name}" 超过 50MB 限制`)
continue
setError(`文件 "${file.name}" 超过 50MB 限制`);
continue;
}
const base64 = await readFileAsBase64(file)
const mimeType = file.type || 'application/octet-stream'
const mediaType = getMediaType(mimeType)
const base64 = await readFileAsBase64(file);
const mimeType = file.type || 'application/octet-stream';
const mediaType = getMediaType(mimeType);
const attachment: Attachment = {
path: file.name,
@ -169,91 +182,91 @@ export function MessageInput({
mime_type: mimeType,
content_base64: base64,
file_name: file.name,
}
};
const fileAttachment: FileAttachment = {
id: crypto.randomUUID(),
file,
attachment,
preview: mediaType === 'image' ? base64 : undefined,
}
};
setAttachments(prev => [...prev, fileAttachment])
setAttachments((prev) => [...prev, fileAttachment]);
}
}
// 否则让默认的文本粘贴行为继续
}
};
// 拖拽事件
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.preventDefault();
e.stopPropagation();
if (!disabled && !isReadOnly) {
setIsDragging(true)
setIsDragging(true);
}
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.preventDefault();
e.stopPropagation();
// 检查是否真的离开了拖拽区域(而不是进入子元素)
const relatedTarget = e.relatedTarget as Node | null
const currentTarget = e.currentTarget
const relatedTarget = e.relatedTarget as Node | null;
const currentTarget = e.currentTarget;
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
setIsDragging(false)
setIsDragging(false);
}
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (!disabled && !isReadOnly) {
handleFileSelect(e.dataTransfer.files)
handleFileSelect(e.dataTransfer.files);
}
}
};
const handleSend = () => {
const hasContent = content.trim() || attachments.length > 0
const hasContent = content.trim() || attachments.length > 0;
if (hasContent && !disabled && !isReadOnly) {
onSend(
content.trim(),
attachments.map(a => a.attachment)
)
setContent('')
setAttachments([])
setError(null)
attachments.map((a) => a.attachment),
);
setContent('');
setAttachments([]);
setError(null);
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = 'auto';
}
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
e.preventDefault();
handleSend();
}
}
};
// 获取附件图标
const getAttachmentIcon = (mediaType: string) => {
switch (mediaType) {
case 'image':
return <ImageIcon className="h-4 w-4" />
return <ImageIcon className="h-4 w-4" />;
case 'audio':
return <MusicIcon className="h-4 w-4" />
return <MusicIcon className="h-4 w-4" />;
case 'video':
return <VideoIcon className="h-4 w-4" />
return <VideoIcon className="h-4 w-4" />;
default:
return <FileIcon className="h-4 w-4" />
return <FileIcon className="h-4 w-4" />;
}
}
};
// 只读模式:显示提示占位符
if (isReadOnly) {
@ -273,14 +286,12 @@ export function MessageInput({
'当前通道仅支持查看历史消息'
)}
</p>
<p className="text-xs text-[var(--text-muted)]">
WebSocket
</p>
<p className="text-xs text-[var(--text-muted)]"> WebSocket </p>
</div>
</div>
</div>
</div>
)
);
}
return (
@ -337,9 +348,7 @@ export function MessageInput({
{/* 拖拽提示 */}
{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="text-[var(--accent-cyan)] text-sm font-medium">
</div>
<div className="text-[var(--accent-cyan)] text-sm font-medium"></div>
</div>
)}
@ -405,5 +414,5 @@ export function MessageInput({
</div>
</div>
</div>
)
);
}

View File

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

View File

@ -1,151 +1,157 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'
import type { ModelOptionsResponse } from '../Settings/types'
import { useState, useEffect, useRef, useCallback } from 'react';
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts';
import type { ModelOptionsResponse } from '../Settings/types';
interface ModelSelectorProps {
sessionId: string | null
sessionId: string | null;
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
settingsClosedTick?: number
settingsClosedTick?: number;
/** 选择变化回调(参数为生效的 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) {
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null)
const [userProvider, setUserProvider] = 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)
export function ModelSelector({
sessionId,
settingsClosedTick,
onSelectionChange,
}: ModelSelectorProps) {
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
const [userProvider, setUserProvider] = 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 中暂存的选择,点击应用后才提交
const [draftProvider, setDraftProvider] = useState<string>('')
const [draftModel, setDraftModel] = useState<string>('')
const [draftProvider, setDraftProvider] = useState<string>('');
const [draftModel, setDraftModel] = useState<string>('');
const containerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null);
// 刷新当前会话的用户模型覆盖
const refreshSelection = useCallback(() => {
if (!sessionId) {
setUserProvider(null)
setUserModel(null)
return
setUserProvider(null);
setUserModel(null);
return;
}
setLoading(true)
setError(null)
setLoading(true);
setError(null);
getSelectedModel(sessionId)
.then(data => {
setUserProvider(data.provider)
setUserModel(data.model)
.then((data) => {
setUserProvider(data.provider);
setUserModel(data.model);
})
.catch(() => {
setUserProvider(null)
setUserModel(null)
setUserProvider(null);
setUserModel(null);
})
.finally(() => setLoading(false))
}, [sessionId])
.finally(() => setLoading(false));
}, [sessionId]);
// 加载模型选项(全局缓存,仅加载一次)
useEffect(() => {
if (modelOptions) return
listModelOptions().then(data => {
if (data) setModelOptions(data)
})
}, [modelOptions])
if (modelOptions) return;
listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
}, [modelOptions]);
// sessionId 变化时刷新用户选择
useEffect(() => {
refreshSelection()
}, [refreshSelection])
refreshSelection();
}, [refreshSelection]);
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
useEffect(() => {
if (settingsClosedTick === undefined) return
refreshSelection()
if (settingsClosedTick === undefined) return;
refreshSelection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsClosedTick])
}, [settingsClosedTick]);
// 计算生效模型并通知父组件
const overridden = userProvider !== null || userModel !== null
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? ''
const effectiveModel = userModel ?? modelOptions?.current.model ?? ''
const overridden = userProvider !== null || userModel !== null;
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '';
const effectiveModel = userModel ?? modelOptions?.current.model ?? '';
useEffect(() => {
if (!modelOptions) return
if (!modelOptions) return;
onSelectionChange?.({
provider: effectiveProvider,
model: effectiveModel,
overridden,
})
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveProvider, effectiveModel, overridden, modelOptions])
}, [effectiveProvider, effectiveModel, overridden, modelOptions]);
// 点击外部关闭 dropdown
useEffect(() => {
if (!open) return
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setOpen(false);
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
const handleToggleOpen = () => {
const next = !open
setOpen(next)
const next = !open;
setOpen(next);
if (next) {
// 打开时刷新选项与当前选择,同步草稿
if (!modelOptions) {
listModelOptions().then(data => { if (data) setModelOptions(data) })
listModelOptions().then((data) => {
if (data) setModelOptions(data);
});
}
refreshSelection()
setDraftProvider(userProvider ?? '')
setDraftModel(userModel ?? '')
refreshSelection();
setDraftProvider(userProvider ?? '');
setDraftModel(userModel ?? '');
}
}
};
const handleApply = async () => {
if (!sessionId) return
const provider = draftProvider.trim() || null
const model = draftModel.trim() || null
setSaving(true)
setError(null)
if (!sessionId) return;
const provider = draftProvider.trim() || null;
const model = draftModel.trim() || null;
setSaving(true);
setError(null);
try {
const result = await selectModel(sessionId, provider, model)
const result = await selectModel(sessionId, provider, model);
if (!result.success) {
setError(result.error || '切换模型失败')
setTimeout(() => setError(null), 3000)
return
setError(result.error || '切换模型失败');
setTimeout(() => setError(null), 3000);
return;
}
setUserProvider(provider)
setUserModel(model)
setOpen(false)
setUserProvider(provider);
setUserModel(model);
setOpen(false);
} catch {
setError('网络错误,切换模型失败')
setTimeout(() => setError(null), 3000)
setError('网络错误,切换模型失败');
setTimeout(() => setError(null), 3000);
} finally {
setSaving(false)
setSaving(false);
}
}
};
const handleReset = () => {
setDraftProvider('')
setDraftModel('')
}
setDraftProvider('');
setDraftModel('');
};
if (!sessionId) return null
if (!sessionId) return null;
// 草稿是否与已保存状态不同(用于启用"应用"按钮)
const draftChanged =
(draftProvider || null) !== (userProvider ?? null) ||
(draftModel || null) !== (userModel ?? null)
(draftModel || null) !== (userModel ?? null);
const buttonLabel = overridden
? `${effectiveProvider}/${effectiveModel}`
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`;
return (
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
@ -154,7 +160,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
onClick={handleToggleOpen}
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"
title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`}
title={
overridden
? `用户覆盖: ${effectiveProvider}/${effectiveModel}`
: `继承默认: ${effectiveProvider}/${effectiveModel}`
}
>
{loading ? (
<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)]'}`}
/>
)}
<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}
</span>
<ChevronDown
@ -172,9 +184,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
</button>
{open && (
<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"
>
<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">
<div className="text-xs text-[var(--text-muted)]">
{overridden
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
@ -186,12 +196,16 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
Provider
<select
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"
>
<option value="">{modelOptions ? `${modelOptions.current.provider}` : ''}</option>
{(modelOptions?.providers ?? []).map(p => (
<option key={p} value={p}>{p}</option>
<option value="">
{modelOptions ? `${modelOptions.current.provider}` : ''}
</option>
{(modelOptions?.providers ?? []).map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
@ -200,20 +214,22 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
Model
<select
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"
>
<option value="">{modelOptions ? `${modelOptions.current.model}` : ''}</option>
{(modelOptions?.models ?? []).map(m => (
<option key={m} value={m}>{m}</option>
<option value="">
{modelOptions ? `${modelOptions.current.model}` : ''}
</option>
{(modelOptions?.models ?? []).map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
</div>
{error && (
<div className="text-xs text-red-400 truncate">{error}</div>
)}
{error && <div className="text-xs text-red-400 truncate">{error}</div>}
<div className="flex items-center justify-between gap-2 pt-1">
<button
@ -232,7 +248,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
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"
>
{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>
</div>
@ -240,9 +260,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
</div>
)}
</div>
{error && (
<span className="text-xs text-red-400 truncate">{error}</span>
)}
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
</div>
)
);
}

View File

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

View File

@ -1,8 +1,8 @@
import { Wifi, WifiOff, Loader2 } from 'lucide-react'
import type { ConnectionStatus } from '../types/protocol'
import { Wifi, WifiOff, Loader2 } from 'lucide-react';
import type { ConnectionStatus } from '../types/protocol';
interface ConnectionStatusProps {
status: ConnectionStatus
status: ConnectionStatus;
}
export function ConnectionStatus({ status }: ConnectionStatusProps) {
@ -13,36 +13,38 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) {
icon: <Loader2 className="h-3 w-3 animate-spin" />,
text: '连接中',
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
}
};
case 'connected':
return {
icon: <Wifi className="h-3 w-3" />,
text: '已连接',
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
}
};
case 'disconnected':
return {
icon: <WifiOff className="h-3 w-3" />,
text: '已断开',
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
}
};
case 'error':
return {
icon: <WifiOff className="h-3 w-3" />,
text: '连接错误',
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 (
<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}
<span>{config.text}</span>
</div>
)
);
}

View File

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

View File

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

View File

@ -1,80 +1,167 @@
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 type { MemorySummary, Command } from '../../types/protocol'
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 type { MemorySummary, Command } from '../../types/protocol';
/* ── types ────────────────────────────────────────────── */
interface MemoryPanelProps {
memories: MemorySummary[]
onRefresh: () => void
onClose?: () => void
onCreateMemory: (ns: string, key: string, content: string) => Command
onUpdateMemory: (id: string, content: string) => Command
onDeleteMemory: (id: string) => Command
sendCommand: (cmd: Command) => void
memories: MemorySummary[];
onRefresh: () => void;
onClose?: () => void;
onCreateMemory: (ns: string, key: string, content: string) => Command;
onUpdateMemory: (id: string, content: string) => Command;
onDeleteMemory: (id: string) => Command;
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> = {
user: { label: '用户记忆', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-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' },
}
user: {
label: '用户记忆',
icon: User,
accent: 'text-cyan-400',
accentBorder: 'border-cyan-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 {
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 ──────────────────────── */
function MemoryCard({ memory, config, onUpdate, onDelete }:
{ 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)
function MemoryCard({
memory,
config,
onUpdate,
onDelete,
}: {
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 = () => {
if (editContent.trim() && editContent !== memory.content) {
onUpdate(memory.id, editContent.trim())
onUpdate(memory.id, editContent.trim());
}
setEditing(false)
}
setEditing(false);
};
const handleDelete = () => {
if (confirmDelete) {
onDelete(memory.id)
onDelete(memory.id);
} else {
setConfirmDelete(true)
setTimeout(() => setConfirmDelete(false), 3000)
setConfirmDelete(true);
setTimeout(() => setConfirmDelete(false), 3000);
}
}
};
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">
{/* header row */}
<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)}
</span>
{/* action buttons — visible on hover */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button 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="编辑">
<button
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" />
</button>
<button 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 ? '再次点击确认删除' : '删除'}>
<button
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" />
</button>
</div>
@ -83,11 +170,23 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
{/* content */}
{editing ? (
<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]"
autoFocus onKeyDown={e => { 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="保存">
autoFocus
onKeyDown={(e) => {
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" />
</button>
</div>
@ -96,95 +195,180 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
)}
</div>
</div>
)
);
}
/* ── Add memory form ───────────────────────────────────── */
function AddMemoryForm({ onAdd, onCancel }: { onAdd: (ns: string, key: string, content: string) => void; onCancel: () => void }) {
const [ns, setNs] = useState('user')
const [key, setKey] = useState('')
const [content, setContent] = useState('')
function AddMemoryForm({
onAdd,
onCancel,
}: {
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 = () => {
if (!key.trim() || !content.trim()) return
onAdd(ns, key.trim(), content.trim())
}
if (!key.trim() || !content.trim()) return;
onAdd(ns, key.trim(), content.trim());
};
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="flex gap-2">
<select value={ns} 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
value={ns}
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>
<input 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)]" />
<input
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>
<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]"
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">
<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 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>
<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
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>
)
);
}
/* ── Section header ────────────────────────────────────── */
function SectionHeader({ config, count, isCollapsed, onClick }:
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
const Icon = config.icon
function SectionHeader({
config,
count,
isCollapsed,
onClick,
}: {
config: NamespaceConfig;
count: number;
isCollapsed: boolean;
onClick: () => void;
}) {
const Icon = config.icon;
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">
{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}`}>
<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"
>
{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" />
</div>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
{config.label}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
{count}
</span>
</button>
)
);
}
/* ── 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>>(() => {
try { const s = localStorage.getItem('picobot-memory-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() }
catch (_) { return new Set() }
})
const [showAddForm, setShowAddForm] = useState(false)
try {
const s = localStorage.getItem('picobot-memory-collapsed');
return s ? new Set(JSON.parse(s)) : new Set();
} catch (_) {
return new Set();
}
});
const [showAddForm, setShowAddForm] = useState(false);
const toggle = (ns: string) => {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(ns)) { next.delete(ns) } else { next.add(ns) }
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]))
return next
})
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(ns)) {
next.delete(ns);
} 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[]>()
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 order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other'];
const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a); const bi = order.indexOf(b)
if (ai !== -1 && bi !== -1) return ai - bi
if (ai !== -1) return -1; if (bi !== -1) return 1
return a.localeCompare(b)
})
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai !== -1 && bi !== -1) return ai - bi;
if (ai !== -1) return -1;
if (bi !== -1) return 1;
return a.localeCompare(b);
});
const handleCreate = (ns: string, key: string, content: string) => {
sendCommand(onCreateMemory(ns, key, content))
setShowAddForm(false)
}
const handleUpdate = (id: string, content: string) => { sendCommand(onUpdateMemory(id, content)) }
const handleDelete = (id: string) => { sendCommand(onDeleteMemory(id)) }
sendCommand(onCreateMemory(ns, key, content));
setShowAddForm(false);
};
const handleUpdate = (id: string, content: string) => {
sendCommand(onUpdateMemory(id, content));
};
const handleDelete = (id: string) => {
sendCommand(onDeleteMemory(id));
};
return (
<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)]" />
</div>
<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">
<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" />
</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" />
</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>
{/* 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 */}
{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" />
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
</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>
)}
@ -225,23 +437,36 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
{/* list */}
{memories.length > 0 && (
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
{sorted.map(ns => {
const c = cfg(ns)
const items = grouped.get(ns)!
const closed = collapsed.has(ns)
{sorted.map((ns) => {
const c = cfg(ns);
const items = grouped.get(ns)!;
const closed = collapsed.has(ns);
return (
<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 && (
<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>
)
);
}

View File

@ -1,48 +1,84 @@
import { useState } from 'react'
import { Package, User, Folder, RefreshCw, ChevronDown, ChevronRight, BookOpen } from 'lucide-react'
import type { SkillSummary } from '../../types/protocol'
import { useState } from 'react';
import {
Package,
User,
Folder,
RefreshCw,
ChevronDown,
ChevronRight,
BookOpen,
} from 'lucide-react';
import type { SkillSummary } from '../../types/protocol';
/* ── types ────────────────────────────────────────────── */
interface SkillListProps {
skills: SkillSummary[]
onRefresh: () => void
skills: SkillSummary[];
onRefresh: () => void;
}
interface SourceConfig {
label: string
icon: typeof Package
accent: string
label: string;
icon: typeof Package;
accent: string;
}
const SOURCE_CONFIG: Record<string, SourceConfig> = {
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
useropenclaw:{ label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
projectagent:{ label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' },
}
};
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 ────────────────────────────────────── */
function SectionHeader({ config, count, isCollapsed, onClick }:
{ config: SourceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
const Icon = config.icon
function SectionHeader({
config,
count,
isCollapsed,
onClick,
}: {
config: SourceConfig;
count: number;
isCollapsed: boolean;
onClick: () => void;
}) {
const Icon = config.icon;
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">
{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}`}>
<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"
>
{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" />
</div>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
{config.label}
</span>
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
{count}
</span>
</button>
)
);
}
/* ── 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}`}>
{skill.name}
</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>
)
);
}
/* ── main component ────────────────────────────────────── */
export function SkillList({ skills, onRefresh }: SkillListProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
try { const s = localStorage.getItem('picobot-skill-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() }
catch (_) { return new Set() }
})
try {
const s = localStorage.getItem('picobot-skill-collapsed');
return s ? new Set(JSON.parse(s)) : new Set();
} catch (_) {
return new Set();
}
});
const toggle = (source: string) => {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(source)) { next.delete(source) } else { next.add(source) }
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next]))
return next
})
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(source)) {
next.delete(source);
} 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[]>()
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 order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw'];
const sorted = Array.from(grouped.keys()).sort((a, b) => {
const ai = order.indexOf(a); const bi = order.indexOf(b)
if (ai !== -1 && bi !== -1) return ai - bi
if (ai !== -1) return -1; if (bi !== -1) return 1
return a.localeCompare(b)
})
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai !== -1 && bi !== -1) return ai - bi;
if (ai !== -1) return -1;
if (bi !== -1) return 1;
return a.localeCompare(b);
});
return (
<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)]" />
</div>
<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">
<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" />
</button>
</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" />
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
</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>
)}
@ -120,23 +183,30 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
{/* list */}
{skills.length > 0 && (
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
{sorted.map(source => {
const cfg = sourceConfig(source)
const items = grouped.get(source)!
const closed = collapsed.has(source)
{sorted.map((source) => {
const cfg = sourceConfig(source);
const items = grouped.get(source)!;
const closed = collapsed.has(source);
return (
<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 && (
<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>
)
);
}

View File

@ -1,39 +1,43 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react'
import type { TodoItemSummary, Command } from '../../types/protocol'
import { useState, useCallback, useEffect, useRef } from 'react';
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react';
import type { TodoItemSummary, Command } from '../../types/protocol';
interface TodoPanelProps {
todos: TodoItemSummary[]
requestTodoList: () => Command
sendCommand: (cmd: Command) => void
onTodoClick?: (todo: TodoItemSummary) => void
todos: TodoItemSummary[];
requestTodoList: () => Command;
sendCommand: (cmd: Command) => void;
onTodoClick?: (todo: TodoItemSummary) => void;
}
/* ── status config ────────────────────────────────────── */
interface StatusCfg { label: string; color: string; dot: string }
interface StatusCfg {
label: string;
color: string;
dot: string;
}
const STATUS: Record<string, StatusCfg> = {
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
}
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
};
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[]> {
const map = new Map<string, TodoItemSummary[]>()
const map = new Map<string, TodoItemSummary[]>();
for (const t of todos) {
const list = map.get(t.status) ?? []
list.push(t)
map.set(t.status, list)
const list = map.get(t.status) ?? [];
list.push(t);
map.set(t.status, list);
}
return map
return map;
}
/* ── 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="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
</span>
)
);
}
/* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled']))
const prevTodoIdsRef = useRef<Set<string>>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
() => new Set(['completed', 'cancelled']),
);
const prevTodoIdsRef = useRef<Set<string>>(new Set());
// 新增待办时自动展开"进行中"分组
useEffect(() => {
const newIds = new Set(todos.map(t => t.id))
const newIds = new Set(todos.map((t) => t.id));
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) {
setCollapsedGroups(prev => {
const next = new Set(prev)
next.delete('in_progress')
return next
})
setCollapsedGroups((prev) => {
const next = new Set(prev);
next.delete('in_progress');
return next;
});
}
}
prevTodoIdsRef.current = newIds
}, [todos])
prevTodoIdsRef.current = newIds;
}, [todos]);
const grouped = groupTodos(todos)
const inProgressCount = grouped.get('in_progress')?.length ?? 0
const totalCount = todos.length
const grouped = groupTodos(todos);
const inProgressCount = grouped.get('in_progress')?.length ?? 0;
const totalCount = todos.length;
const toggleGroup = useCallback((status: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(status)) next.delete(status); else next.add(status)
return next
})
}, [])
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(status)) next.delete(status);
else next.add(status);
return next;
});
}, []);
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
const handleRefresh = useCallback(
() => sendCommand(requestTodoList()),
[sendCommand, requestTodoList],
);
return (
<div className="flex h-full flex-col">
{/* title bar */}
<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" />
<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>
{inProgressCount > 0 && <PulseDot />}
<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" />
</button>
</div>
@ -115,12 +131,12 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
</div>
)}
{GROUP_ORDER.map(status => {
const items = grouped.get(status)
if (!items || items.length === 0) return null
{GROUP_ORDER.map((status) => {
const items = grouped.get(status);
if (!items || items.length === 0) return null;
const cfg = statusCfg(status)
const isCollapsed = collapsedGroups.has(status)
const cfg = statusCfg(status);
const isCollapsed = collapsedGroups.has(status);
return (
<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={`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">
<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>
</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">
{items.map(item => (
{items.map((item) => (
<button
key={item.id}
onClick={() => onTodoClick?.(item)}
@ -153,9 +175,9 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
</div>
</div>
</div>
)
);
})}
</div>
</div>
)
);
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,9 +1,21 @@
// Config-related constants extracted from ConfigPage.tsx
import {
Settings, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
Plug, Radio, Wifi, Server, Users, UserCheck,
} from 'lucide-react'
import type { TabId } from './types'
Settings,
Cpu,
Bot,
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 }[] = [
{ 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: 'connection', label: '连接', icon: Wifi },
{ 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 selectCls = inputCls
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 selectCls = inputCls;
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
{ 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: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
{ value: 'UTC', label: 'UTC (协调世界时)' },
]
];

View File

@ -1,200 +1,277 @@
// 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 ModelConfig { model_id: string; temperature?: number; max_tokens?: number; 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 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 ModelConfig {
model_id: string;
temperature?: number;
max_tokens?: number;
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 {
name?: string
type: 'stdio' | 'streamableHttp' | 'http'
is_active: boolean
command?: string
args?: string[]
env?: Record<string, string>
cwd?: string
base_url?: string
headers?: Record<string, string>
description?: string
name?: string;
type: 'stdio' | 'streamableHttp' | 'http';
is_active: boolean;
command?: string;
args?: string[];
env?: Record<string, string>;
cwd?: string;
base_url?: string;
headers?: Record<string, string>;
description?: string;
}
export interface SkillItem {
name: string
description: string
source: string
path: string
disabled_in_scopes: string[]
name: string;
description: string;
source: string;
path: string;
disabled_in_scopes: string[];
}
export interface SkillListResponse {
skills_system_enabled: boolean
total: number
skills: SkillItem[]
skills_system_enabled: boolean;
total: number;
skills: SkillItem[];
}
export interface ToolItem {
name: string
description: string
name: string;
description: string;
/** "builtin" 或 "mcp:{server_key}" */
source: string
source: string;
}
export interface ToolsListResponse {
total: number
tools: ToolItem[]
total: number;
tools: ToolItem[];
}
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
export interface ModelOptionsResponse {
providers: string[]
models: string[]
providers: string[];
models: string[];
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
current: { provider: string, model: string }
current: { provider: string; model: string };
}
export interface CapabilityPolicy {
allowed_skills?: string[]
denied_skills: string[]
allowed_tools?: string[]
denied_tools: string[]
allowed_subagents?: string[]
denied_subagents: string[]
allowed_skills?: string[];
denied_skills: string[];
allowed_tools?: string[];
denied_tools: string[];
allowed_subagents?: string[];
denied_subagents: string[];
}
export interface SubagentItem {
name: string
description: string
source: string
disabled_in_scopes: string[]
capability?: CapabilityPolicy
provider?: string
model?: string
name: string;
description: string;
source: string;
disabled_in_scopes: string[];
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}
export interface SubagentListResponse {
subagents_system_enabled: boolean
total: number
subagents: SubagentItem[]
subagents_system_enabled: boolean;
total: number;
subagents: SubagentItem[];
}
export interface ExpertsConfig { enabled: boolean; sources: string[] }
export interface ExpertsConfig {
enabled: boolean;
sources: string[];
}
export interface ExpertItem {
name: string
description: string
source: string
path?: string
body?: string
disabled_in_scopes: string[]
capability?: CapabilityPolicy
provider?: string
model?: string
name: string;
description: string;
source: string;
path?: string;
body?: string;
disabled_in_scopes: string[];
capability?: CapabilityPolicy;
provider?: string;
model?: string;
}
export interface ExpertListResponse {
experts_system_enabled: boolean
total: number
experts: ExpertItem[]
experts_system_enabled: boolean;
total: number;
experts: ExpertItem[];
}
export interface McpServerStatus {
key: string
name: string
transport_type: string
is_active: boolean
connected: boolean
tool_count: number
error?: string
key: string;
name: string;
transport_type: string;
is_active: boolean;
connected: boolean;
tool_count: number;
error?: string;
}
export interface McpStatusResponse {
enabled: boolean
total_servers: number
connected_servers: number
failed_servers: number
total_tools: number
servers: McpServerStatus[]
enabled: boolean;
total_servers: number;
connected_servers: number;
failed_servers: number;
total_tools: number;
servers: McpServerStatus[];
}
export interface FeishuChannelConfig {
enabled: boolean
app_id: string
app_secret: string
allow_from?: string[]
agent?: string
media_dir?: string
reaction_emoji?: string
max_message_chars?: number
reply_context_max_chars?: number
enabled: boolean;
app_id: string;
app_secret: string;
allow_from?: string[];
agent?: string;
media_dir?: string;
reaction_emoji?: string;
max_message_chars?: number;
reply_context_max_chars?: number;
}
export interface WechatChannelConfig {
enabled: boolean
base_url: string
cred_path: string
force_login?: boolean
allow_from?: string[]
agent?: string
enabled: boolean;
base_url: string;
cred_path: string;
force_login?: boolean;
allow_from?: string[];
agent?: string;
}
export interface ChannelConfig {
type?: string
enabled?: boolean
app_id?: string
app_secret?: string
agent?: string
base_url?: string
cred_path?: string
force_login?: boolean
allow_from?: string[]
media_dir?: string
reaction_emoji?: string
max_message_chars?: number
reply_context_max_chars?: number
[key: string]: unknown
type?: string;
enabled?: boolean;
app_id?: string;
app_secret?: string;
agent?: string;
base_url?: string;
cred_path?: string;
force_login?: boolean;
allow_from?: string[];
media_dir?: string;
reaction_emoji?: string;
max_message_chars?: number;
reply_context_max_chars?: number;
[key: string]: unknown;
}
export interface SchedulerJobConfig {
id: string
enabled: boolean
kind: string
[key: string]: unknown
id: string;
enabled: boolean;
kind: string;
[key: string]: unknown;
}
export interface AppConfig {
providers: Record<string, ProviderConfig>
models: Record<string, ModelConfig>
agents: Record<string, AgentConfig>
time: TimeConfig
gateway: GatewayConfig
scheduler: SchedulerConfig
skills: SkillsConfig
tools: ToolsConfig
memory_maintenance: MemoryMaintenanceConfig
image_context: ImageContextConfig
subagents: SubagentsConfig
experts: ExpertsConfig
client: ClientConfig
channels: Record<string, ChannelConfig>
mcpServers: Record<string, McpServerConfig>
providers: Record<string, ProviderConfig>;
models: Record<string, ModelConfig>;
agents: Record<string, AgentConfig>;
time: TimeConfig;
gateway: GatewayConfig;
scheduler: SchedulerConfig;
skills: SkillsConfig;
tools: ToolsConfig;
memory_maintenance: MemoryMaintenanceConfig;
image_context: ImageContextConfig;
subagents: SubagentsConfig;
experts: ExpertsConfig;
client: ClientConfig;
channels: Record<string, ChannelConfig>;
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 {
onClose: () => void
onSaveConnection?: (host: string, port: number) => void
initialTab?: TabId
onClose: () => void;
onSaveConnection?: (host: string, port: number) => void;
initialTab?: TabId;
}
export interface KnownSource {
key: string
label: string
description: string
key: string;
label: string;
description: string;
}

View File

@ -1,70 +1,129 @@
// Shared UI primitives extracted from ConfigPage.tsx
import { useState, type ReactNode } from 'react'
import { X, Plus, Trash2 } from 'lucide-react'
import { inputCls } from './constants'
import type { KnownSource } from './types'
import { useState, type ReactNode } from 'react';
import { X, Plus, Trash2 } from 'lucide-react';
import { inputCls } from './constants';
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 (
<div className="space-y-1.5">
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
{children}
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
</div>
)
);
}
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
export function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
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)]'}`}
>
<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>
)
);
}
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
const [input, setInput] = useState('')
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } }
const [input, setInput] = useState('');
const add = () => {
const v = input.trim();
if (v && !tags.includes(v)) {
onChange([...tags, v]);
setInput('');
}
};
return (
<div className="space-y-2">
<div className="flex flex-wrap gap-1.5">
{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}
<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>
))}
</div>
<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'} />
<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">
<input
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" />
</button>
</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 (
<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="flex items-center gap-2">
<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 className="p-4 space-y-4">{children}</div>
</div>
)
);
}
/** 模态框标题栏:图标 + 标题 + 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 (
<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}
@ -78,7 +137,7 @@ export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title:
<X className="h-5 w-5" />
</button>
</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">
{children}
</div>
)
);
}
export function SourceEditor({
@ -97,41 +156,41 @@ export function SourceEditor({
examplePaths,
showCustom = true,
}: {
sources: string[]
onChange: (s: string[]) => void
knownSources: KnownSource[]
examplePaths?: string[]
showCustom?: boolean
sources: string[];
onChange: (s: string[]) => void;
knownSources: KnownSource[];
examplePaths?: string[];
showCustom?: boolean;
}) {
const [customInput, setCustomInput] = useState('')
const knownKeys = new Set(knownSources.map(k => k.key))
const customPaths = sources.filter(s => !knownKeys.has(s))
const [customInput, setCustomInput] = useState('');
const knownKeys = new Set(knownSources.map((k) => k.key));
const customPaths = sources.filter((s) => !knownKeys.has(s));
const toggleKnown = (key: string) => {
if (sources.includes(key)) {
onChange(sources.filter(s => s !== key))
onChange(sources.filter((s) => s !== key));
} else {
onChange([...sources, key])
onChange([...sources, key]);
}
}
};
const addCustom = () => {
const v = customInput.trim()
const v = customInput.trim();
if (v && !sources.includes(v)) {
onChange([...sources, v])
setCustomInput('')
onChange([...sources, v]);
setCustomInput('');
}
}
};
const removeCustom = (path: string) => {
onChange(sources.filter(s => s !== path))
}
onChange(sources.filter((s) => s !== path));
};
return (
<div className="space-y-4">
{/* Known sources as toggles */}
<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 className="flex-1 min-w-0">
<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) */}
{showCustom && (
<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 && (
<div className="flex flex-wrap gap-1.5">
{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}
<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>
))}
</div>
@ -159,95 +228,107 @@ export function SourceEditor({
<div className="flex gap-2">
<input
value={customInput}
onChange={e => setCustomInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
onChange={(e) => setCustomInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustom())}
placeholder="输入绝对路径,如 D:\my-skills"
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" />
</button>
</div>
{examplePaths && (
<p className="text-xs text-[var(--text-muted)]">
: {examplePaths.join('、')}
</p>
<p className="text-xs text-[var(--text-muted)]">: {examplePaths.join('、')}</p>
)}
</div>
)}
</div>
)
);
}
export interface CheckboxListOption {
key: string
label: string
description?: string
key: string;
label: string;
description?: string;
/** 可选分组标识,配合 groupBy 使用 */
group?: string
group?: string;
}
interface CheckboxListProps {
options: CheckboxListOption[]
selected: string[]
onChange: (selected: string[]) => void
options: CheckboxListOption[];
selected: string[];
onChange: (selected: string[]) => void;
/** 未在 options 中出现但已选中的值legacy 数据),以可移除标签形式展示 */
extraSelected?: string[]
emptyHint?: string
extraSelected?: string[];
emptyHint?: string;
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx" */
groupBy?: (option: CheckboxListOption) => string
groupBy?: (option: CheckboxListOption) => string;
}
/**
* 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) =>
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 optionsInList = new Set(options.map(o => o.key))
const selectedSet = new Set(selected);
const optionsInList = new Set(options.map((o) => o.key));
// 仅展示未出现在 options 中的额外已选值
const extra = extraSelected.filter(k => !optionsInList.has(k))
const extra = extraSelected.filter((k) => !optionsInList.has(k));
// 分组渲染
const renderOptions = (opts: CheckboxListOption[]) => (
<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 className="flex-1 min-w-0">
<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>
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
</div>
))}
</div>
)
);
let body: ReactNode
let body: ReactNode;
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) {
const groups = new Map<string, CheckboxListOption[]>()
const groups = new Map<string, CheckboxListOption[]>();
for (const opt of options) {
const g = groupBy(opt)
const arr = groups.get(g) ?? []
arr.push(opt)
groups.set(g, arr)
const g = groupBy(opt);
const arr = groups.get(g) ?? [];
arr.push(opt);
groups.set(g, arr);
}
body = (
<div className="space-y-3">
{Array.from(groups.entries()).map(([g, opts]) => (
<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)}
</div>
))}
</div>
)
);
} else {
body = renderOptions(options)
body = renderOptions(options);
}
return (
@ -255,33 +336,70 @@ export function CheckboxList({ options, selected, onChange, extraSelected = [],
{body}
{extra.length > 0 && (
<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">
{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">
{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"
>
{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>
))}
</div>
</div>
)}
</div>
)
);
}
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
const [editing, setEditing] = useState(false)
const [val, setVal] = useState(name)
export function MapEntryHeader({
name,
onDelete,
onRename,
}: {
name: string;
onDelete: () => void;
onRename?: (n: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(name);
return (
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
{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" />
<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>
)
);
}

View File

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

View File

@ -1,9 +1,9 @@
import { Wifi, FolderOpen, Hash } from 'lucide-react'
import type { Session } from '../../types/protocol'
import { Wifi, FolderOpen, Hash } from 'lucide-react';
import type { Session } from '../../types/protocol';
interface SessionInfoProps {
session: Session | null
connectionId: string | null
session: Session | null;
connectionId: string | null;
}
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="flex items-center gap-2 mb-2">
<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>
{session ? (
@ -46,5 +48,5 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
)}
</div>
</div>
)
);
}

View File

@ -1,32 +1,45 @@
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 type { Topic } from '../../types/protocol'
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 type { Topic } from '../../types/protocol';
interface TopicListProps {
sessionId: string | null
topics: Topic[]
currentTopicId: string | null
isReadOnly: boolean
onCreateTopic: () => void
onRefresh: () => void
onSwitchTopic: (topicId: string) => void
onDeleteTopic: (topicId: string) => void
onRenameTopic: (topicId: string, title: string) => void
sessionId: string | null;
topics: Topic[];
currentTopicId: string | null;
isReadOnly: boolean;
onCreateTopic: () => void;
onRefresh: () => void;
onSwitchTopic: (topicId: string) => void;
onDeleteTopic: (topicId: string) => void;
onRenameTopic: (topicId: string, title: string) => void;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
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) {
return '昨天'
return '昨天';
} else if (diffDays < 7) {
return `${diffDays}天前`
return `${diffDays}天前`;
} 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,
onRenameTopic,
}: TopicListProps) {
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
const [editingTitle, setEditingTitle] = useState('')
const editInputRef = useRef<HTMLInputElement>(null)
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const editInputRef = useRef<HTMLInputElement>(null);
// 进入编辑模式时自动聚焦 input
useEffect(() => {
if (editingTopicId && editInputRef.current) {
editInputRef.current.focus()
editInputRef.current.select()
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingTopicId])
}, [editingTopicId]);
const startEdit = useCallback((topic: Topic) => {
setConfirmDeleteId(null)
setEditingTopicId(topic.id)
setEditingTitle(topic.title)
}, [])
setConfirmDeleteId(null);
setEditingTopicId(topic.id);
setEditingTitle(topic.title);
}, []);
const cancelEdit = useCallback(() => {
setEditingTopicId(null)
setEditingTitle('')
}, [])
setEditingTopicId(null);
setEditingTitle('');
}, []);
const commitEdit = useCallback(() => {
const trimmed = editingTitle.trim()
const trimmed = editingTitle.trim();
if (!trimmed || !editingTopicId) {
cancelEdit()
return
cancelEdit();
return;
}
onRenameTopic(editingTopicId, trimmed)
setEditingTopicId(null)
setEditingTitle('')
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit])
onRenameTopic(editingTopicId, trimmed);
setEditingTopicId(null);
setEditingTitle('');
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]);
// 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 LIST_PADDING = 24 // p-3 top + bottom
const [pageSize, setPageSize] = useState(8) // fallback before measurement
const [currentPage, setCurrentPage] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
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 [pageSize, setPageSize] = useState(8); // fallback before measurement
const [currentPage, setCurrentPage] = useState(0);
const listRef = useRef<HTMLDivElement>(null);
const measurePageSize = useCallback(() => {
const el = listRef.current
if (!el) return
const available = el.clientHeight - LIST_PADDING
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1))
}, [])
const el = listRef.current;
if (!el) return;
const available = el.clientHeight - LIST_PADDING;
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1));
}, []);
useEffect(() => {
measurePageSize()
const el = listRef.current
if (!el) return
const observer = new ResizeObserver(() => measurePageSize())
observer.observe(el)
return () => observer.disconnect()
}, [measurePageSize])
measurePageSize();
const el = listRef.current;
if (!el) return;
const observer = new ResizeObserver(() => measurePageSize());
observer.observe(el);
return () => observer.disconnect();
}, [measurePageSize]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(topics.length / pageSize)),
[topics.length, pageSize]
)
[topics.length, pageSize],
);
const pagedTopics = useMemo(
() => 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)
useEffect(() => {
if (currentPage >= totalPages) {
setCurrentPage(Math.max(0, totalPages - 1))
setCurrentPage(Math.max(0, totalPages - 1));
}
}, [currentPage, totalPages])
}, [currentPage, totalPages]);
return (
<div className="flex h-full flex-col">
@ -178,8 +191,8 @@ export function TopicList({
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
<form
onSubmit={(e) => {
e.preventDefault()
commitEdit()
e.preventDefault();
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"
>
@ -189,8 +202,8 @@ export function TopicList({
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
e.preventDefault();
cancelEdit();
}
}}
onBlur={cancelEdit}
@ -231,9 +244,13 @@ export function TopicList({
{currentPage * pageSize + index + 1}
</span>
<div className="min-w-0 flex-1">
<div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
}`}>
<div
className={`truncate font-medium ${
topic.id === currentTopicId
? 'text-[var(--accent-cyan)]'
: 'text-[var(--text-secondary)]'
}`}
>
{topic.description || topic.title}
</div>
<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>
<button
onClick={(e) => {
e.stopPropagation()
onDeleteTopic(topic.id)
setConfirmDeleteId(null)
e.stopPropagation();
onDeleteTopic(topic.id);
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"
title="确认"
@ -271,8 +288,8 @@ export function TopicList({
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConfirmDeleteId(null)
e.stopPropagation();
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"
title="取消"
@ -284,8 +301,8 @@ export function TopicList({
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation()
startEdit(topic)
e.stopPropagation();
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"
title="重命名话题"
@ -294,8 +311,8 @@ export function TopicList({
</button>
<button
onClick={(e) => {
e.stopPropagation()
setConfirmDeleteId(topic.id)
e.stopPropagation();
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"
title="删除话题"
@ -317,7 +334,7 @@ export function TopicList({
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
<button
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
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"
>
@ -327,7 +344,7 @@ export function TopicList({
{currentPage + 1} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 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"
>
@ -336,5 +353,5 @@ export function TopicList({
</div>
)}
</div>
)
);
}

View File

@ -9,42 +9,46 @@ import type {
StreamEnd,
ExecutionCompleted,
WsError,
} from '../../types/protocol'
} from '../../types/protocol';
// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref
let messageIdCounter = 0
let messageIdCounter = 0;
export function generateMessageId(): string {
messageIdCounter += 1
return `msg_${Date.now()}_${messageIdCounter}`
messageIdCounter += 1;
return `msg_${Date.now()}_${messageIdCounter}`;
}
/** 重置计数器(仅测试使用) */
export function _resetMessageIdCounterForTests(): void {
messageIdCounter = 0
messageIdCounter = 0;
}
/** 从服务端消息中提取 subagent_task_id如果该消息类型携带此字段 */
export function getSubagentTaskId(message: WsOutbound): string | undefined {
if (message.type === 'tool_call' || 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 === 'tool_call' ||
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') {
return (message as StreamDelta | StreamEnd).subagent_task_id
return (message as StreamDelta | StreamEnd).subagent_task_id;
}
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 */
export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null {
switch (message.type) {
case 'assistant_response': {
const msg = message as AssistantResponse
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
const msg = message as AssistantResponse;
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
return {
id: msg.id,
role: role as ChatMessage['role'],
@ -54,10 +58,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
attachments: msg.attachments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
};
}
case 'tool_call': {
const msg = message as ToolCall
const msg = message as ToolCall;
return {
id: msg.id,
role: 'tool',
@ -69,10 +73,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
arguments: msg.arguments,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_content,
}
};
}
case 'tool_result': {
const msg = message as ToolResult
const msg = message as ToolResult;
return {
id: msg.id,
role: 'tool',
@ -83,10 +87,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
}
};
}
case 'tool_pending': {
const msg = message as ToolPending
const msg = message as ToolPending;
return {
id: msg.id,
role: 'tool',
@ -96,10 +100,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
}
};
}
case 'stream_delta': {
const msg = message as StreamDelta
const msg = message as StreamDelta;
return {
id: msg.id,
role: 'assistant' as const,
@ -108,7 +112,7 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
type: 'message' as const,
subagentTaskId: msg.subagent_task_id,
reasoningContent: msg.reasoning_delta,
}
};
}
case 'error': {
return {
@ -117,9 +121,9 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
content: `Error: ${message.message}`,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
};
}
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 {
taskId: string
description: string
subagentType: string
status: string
summary?: string
messages: ChatMessage[]
taskId: string;
description: string;
subagentType: string;
status: string;
summary?: string;
messages: ChatMessage[];
}
/** 定时任务执行对话查看视图 */
export interface SchedulerJobView {
jobId: string
description: string
channel: string
chatId: string
messages: ChatMessage[]
jobId: string;
description: string;
channel: string;
chatId: string;
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 type { WsInbound, Command } from '../../types/protocol'
import { useState, useCallback, useMemo, useRef } from 'react';
import type { WsInbound, Command } from '../../types/protocol';
export interface UseConnectionReturn {
connectionId: string | null
isConnected: boolean
setConnectionId: (id: string | null) => void
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
connectionId: string | null;
isConnected: boolean;
setConnectionId: (id: string | null) => void;
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
/** 发送命令到后端(封装 command payload 序列化) */
sendCommand: (cmd: Command) => void
sendCommand: (cmd: Command) => void;
}
export function useConnection(): UseConnectionReturn {
const [connectionId, setConnectionId] = useState<string | null>(null)
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
const [connectionId, setConnectionId] = useState<string | null>(null);
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null);
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
sendMessageRef.current = fn
}, [])
sendMessageRef.current = fn;
}, []);
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 {
connectionId,
@ -30,5 +30,5 @@ export function useConnection(): UseConnectionReturn {
setConnectionId,
setSendMessage,
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 {
ChatMessage,
WsOutbound,
@ -13,48 +20,48 @@ import type {
TaskStarted,
Attachment,
Command,
} from '../../types/protocol'
import { generateMessageId, getSubagentTaskId } from './messageMappers'
} from '../../types/protocol';
import { generateMessageId, getSubagentTaskId } from './messageMappers';
interface UseMessagesOptions {
selectedTopicRef: MutableRefObject<string | null>
topicsRef: MutableRefObject<Topic[]>
bumpTopicRefreshTrigger: () => void
selectedTopicRef: MutableRefObject<string | null>;
topicsRef: MutableRefObject<Topic[]>;
bumpTopicRefreshTrigger: () => void;
}
export interface UseMessagesReturn {
messages: ChatMessage[]
setMessages: Dispatch<SetStateAction<ChatMessage[]>>
isLoading: boolean
setIsLoading: Dispatch<SetStateAction<boolean>>
handleMessage: (content: string, attachments?: Attachment[]) => void
clearMessages: () => void
handleStop: () => Command
messages: ChatMessage[];
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
isLoading: boolean;
setIsLoading: Dispatch<SetStateAction<boolean>>;
handleMessage: (content: string, attachments?: Attachment[]) => void;
clearMessages: () => void;
handleStop: () => Command;
/** 处理主视图的消息类 casetask_started, stream_*, tool_*, execution_*, error返回是否已处理 */
handleMainViewMessage: (message: WsOutbound) => boolean
handleMainViewMessage: (message: WsOutbound) => boolean;
}
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isLoading, setIsLoading] = useState(false)
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set())
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set());
const applyUserMessageId = useCallback((userMessageId: string) => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return
syncedUserMessageIdsRef.current.add(userMessageId)
setMessages(prev => {
if (syncedUserMessageIdsRef.current.has(userMessageId)) return;
syncedUserMessageIdsRef.current.add(userMessageId);
setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].role === 'user') {
const updated = [...prev]
updated[i] = { ...updated[i], id: userMessageId }
return updated
const updated = [...prev];
updated[i] = { ...updated[i], id: userMessageId };
return updated;
}
}
return prev
})
}, [])
return prev;
});
}, []);
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
setMessages((prev) => [
@ -67,222 +74,233 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
type: 'message',
attachments: attachments || [],
},
])
setIsLoading(true)
}, [])
]);
setIsLoading(true);
}, []);
const clearMessages = useCallback(() => {
setMessages([])
}, [])
setMessages([]);
}, []);
const handleStop = useCallback((): Command => {
return { type: 'stop_execution' }
}, [])
return { type: 'stop_execution' };
}, []);
const handleMainViewMessage = useCallback((message: WsOutbound): boolean => {
switch (message.type) {
case 'task_started': {
const msg = message as TaskStarted
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
// 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) return true
const handleMainViewMessage = useCallback(
(message: WsOutbound): boolean => {
switch (message.type) {
case 'task_started': {
const msg = message as TaskStarted;
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
// 孙智能体的 TaskStarted 不应 backfill 到主视图
if (msg.parent_task_id) return true;
setMessages((prev) => {
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = prev.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !prev[idx].navigateToTaskId) {
const updated = [...prev]
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
return updated
setMessages((prev) => {
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = prev.findIndex(
(m) =>
m.toolCallId === msg.tool_call_id &&
m.type === 'tool_call' &&
m.toolName === 'task',
);
if (idx >= 0 && !prev[idx].navigateToTaskId) {
const updated = [...prev];
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id };
return updated;
}
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
const updated = [...prev]
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
return updated
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = prev.length - 1; i >= 0; i--) {
if (
prev[i].type === 'tool_call' &&
prev[i].toolName === 'task' &&
!prev[i].navigateToTaskId
) {
const updated = [...prev];
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id };
return updated;
}
}
}
return prev
})
return true
}
return prev;
});
return true;
}
case 'stream_delta': {
const msg = message as StreamDelta
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => {
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
if (existingIdx >= 0) {
const updated = [...prev]
const existing = updated[existingIdx]
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
case 'stream_delta': {
const msg = message as StreamDelta;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => {
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
if (existingIdx >= 0) {
const updated = [...prev];
const existing = updated[existingIdx];
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: 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,
{
id: msg.id,
role: 'assistant' as const,
content: msg.delta,
timestamp: Math.floor(Date.now() / 1000),
type: 'message' as const,
reasoningContent: msg.reasoning_delta,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
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)
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;
}
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,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_call',
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)
return true
}
case 'tool_result': {
const msg = message as ToolResult;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
},
]);
return true;
}
case 'tool_result': {
const msg = message as ToolResult
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: msg.content,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_result',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
subagentTaskId: msg.subagent_task_id,
durationMs: msg.duration_ms,
},
])
return true
}
case 'tool_pending': {
const msg = message as ToolPending;
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
},
]);
return true;
}
case 'tool_pending': {
const msg = message as ToolPending
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
setMessages((prev) => [
...prev,
{
id: msg.id,
role: 'tool',
content: `${msg.content}\n\n${msg.resume_hint}`,
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'tool_pending',
toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
},
])
return true
}
case 'execution_cancelled': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: (message as { type: 'execution_cancelled'; message: string }).message,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
]);
setIsLoading(false);
return true;
}
case 'execution_cancelled': {
setMessages((prev) => [
...prev,
{
id: generateMessageId(),
role: 'assistant',
content: (message as { type: 'execution_cancelled'; message: string }).message,
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
},
])
setIsLoading(false)
return true
}
case 'error': {
if (getSubagentTaskId(message)) return true;
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;
}
case 'error': {
if (getSubagentTaskId(message)) return true
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:
return false;
}
default:
return false
}
}, [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId])
},
[selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId],
);
return {
messages,
@ -293,5 +311,5 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
clearMessages,
handleStop,
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 {
WsOutbound,
SchedulerJobSummary,
SchedulerJobSessionLookup,
Command,
} from '../../types/protocol'
import { serverMessageToChatMessage } from './messageMappers'
import type { SchedulerJobView } from './types'
} from '../../types/protocol';
import { serverMessageToChatMessage } from './messageMappers';
import type { SchedulerJobView } from './types';
export interface UseSchedulerViewReturn {
schedulerView: SchedulerJobView | null
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>
schedulerViewRef: MutableRefObject<SchedulerJobView | null>
schedulerJobs: SchedulerJobSummary[]
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
schedulerView: SchedulerJobView | null;
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>;
schedulerViewRef: MutableRefObject<SchedulerJobView | null>;
schedulerJobs: SchedulerJobSummary[];
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>;
sidebarTab: 'topics' | 'scheduler';
setSidebarTab: (tab: 'topics' | 'scheduler') => void;
requestSchedulerJobList: () => Command;
enterSchedulerJobView: (
lookup: SchedulerJobSessionLookup,
jobId: string,
description: string,
) => Command;
exitSchedulerJobView: () => void;
/** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */
handleSchedulerMessage: (message: WsOutbound) => boolean
handleSchedulerMessage: (message: WsOutbound) => boolean;
}
export function useSchedulerView(): UseSchedulerViewReturn {
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null);
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]);
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics');
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
const schedulerViewRef = useRef<SchedulerJobView | null>(null);
useEffect(() => {
schedulerViewRef.current = schedulerView
}, [schedulerView])
schedulerViewRef.current = schedulerView;
}, [schedulerView]);
const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' }
}, [])
return { type: 'list_scheduler_jobs' };
}, []);
const enterSchedulerJobView = useCallback(
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
@ -46,40 +58,38 @@ export function useSchedulerView(): UseSchedulerViewReturn {
channel: lookup.channel,
chatId: lookup.chat_id,
messages: [],
}
schedulerViewRef.current = newView
setSchedulerView(newView)
};
schedulerViewRef.current = newView;
setSchedulerView(newView);
return {
type: 'load_chat_messages',
channel: lookup.channel,
chat_id: lookup.chat_id,
}
};
},
[]
)
[],
);
const exitSchedulerJobView = useCallback(() => {
schedulerViewRef.current = null
setSchedulerView(null)
}, [])
schedulerViewRef.current = null;
setSchedulerView(null);
}, []);
/** Tier 1 路由调度器视图激活时chat 消息追加到 schedulerView非 chat 消息 fall through */
const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => {
const currentSchedulerView = schedulerViewRef.current
if (!currentSchedulerView) return false
const currentSchedulerView = schedulerViewRef.current;
if (!currentSchedulerView) return false;
const chatMsg = serverMessageToChatMessage(message)
const chatMsg = serverMessageToChatMessage(message);
if (chatMsg) {
setSchedulerView((prev) =>
prev
? { ...prev, messages: [...prev.messages, chatMsg] }
: prev
)
return true
prev ? { ...prev, messages: [...prev.messages, chatMsg] } : prev,
);
return true;
}
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
return false
}, [])
return false;
}, []);
// scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置
return {
@ -94,5 +104,5 @@ export function useSchedulerView(): UseSchedulerViewReturn {
enterSchedulerJobView,
exitSchedulerJobView,
handleSchedulerMessage,
}
};
}

View File

@ -1,46 +1,49 @@
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
import type { SessionSummary, Command } from '../../types/protocol'
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
import type { SessionSummary, Command } from '../../types/protocol';
export interface UseSessionsReturn {
sessions: SessionSummary[]
setSessions: Dispatch<SetStateAction<SessionSummary[]>>
selectedSessionId: string | null
setSelectedSessionId: Dispatch<SetStateAction<string | null>>
session: SessionSummary | null
sessionId: string | null
chatId: string
selectSession: (sessionId: string) => void
requestSessionList: (selectedChannel: string) => Command
sessions: SessionSummary[];
setSessions: Dispatch<SetStateAction<SessionSummary[]>>;
selectedSessionId: string | null;
setSelectedSessionId: Dispatch<SetStateAction<string | null>>;
session: SessionSummary | null;
sessionId: string | null;
chatId: string;
selectSession: (sessionId: string) => void;
requestSessionList: (selectedChannel: string) => Command;
}
interface UseSessionsOptions {
/** selectSession 时额外执行的副作用 */
onSessionChange?: () => void
onSessionChange?: () => void;
}
export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
const [sessions, setSessions] = useState<SessionSummary[]>([])
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
const selectedSession = useMemo(
() => sessions.find(s => s.session_id === selectedSessionId) ?? null,
[sessions, selectedSessionId]
)
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession])
const chatId = useMemo(() => sessionId ?? 'default', [sessionId])
() => sessions.find((s) => s.session_id === selectedSessionId) ?? null,
[sessions, selectedSessionId],
);
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]);
const chatId = useMemo(() => sessionId ?? 'default', [sessionId]);
const selectSession = useCallback((id: string) => {
setSelectedSessionId(id)
options?.onSessionChange?.()
}, [options])
const selectSession = useCallback(
(id: string) => {
setSelectedSessionId(id);
options?.onSessionChange?.();
},
[options],
);
const requestSessionList = useCallback((selectedChannel: string): Command => {
return {
type: 'list_sessions_by_channel',
channel_name: selectedChannel,
include_archived: false,
}
}, [])
};
}, []);
return {
sessions,
@ -52,5 +55,5 @@ export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
chatId,
selectSession,
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 {
MemorySummary,
SkillSummary,
TodoItemSummary,
Channel,
Command,
} from '../../types/protocol'
} from '../../types/protocol';
export interface UseSideDataReturn {
memories: MemorySummary[]
setMemories: Dispatch<SetStateAction<MemorySummary[]>>
skills: SkillSummary[]
setSkills: Dispatch<SetStateAction<SkillSummary[]>>
todos: TodoItemSummary[]
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
memories: MemorySummary[];
setMemories: Dispatch<SetStateAction<MemorySummary[]>>;
skills: SkillSummary[];
setSkills: Dispatch<SetStateAction<SkillSummary[]>>;
todos: TodoItemSummary[];
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
highlightedMessageId: string | null;
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
channels: Channel[]
setChannels: Dispatch<SetStateAction<Channel[]>>
selectedChannel: string
setSelectedChannel: Dispatch<SetStateAction<string>>
isWritable: boolean
channels: Channel[];
setChannels: Dispatch<SetStateAction<Channel[]>>;
selectedChannel: string;
setSelectedChannel: Dispatch<SetStateAction<string>>;
isWritable: boolean;
requestMemoryList: () => Command
createMemory: (namespace: string, key: string, content: string) => Command
updateMemory: (id: string, content: string) => Command
deleteMemory: (id: string) => Command
requestSkillList: () => Command
requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
requestChannelList: () => Command
requestMemoryList: () => Command;
createMemory: (namespace: string, key: string, content: string) => Command;
updateMemory: (id: string, content: string) => Command;
deleteMemory: (id: string) => Command;
requestSkillList: () => Command;
requestTodoList: () => Command;
requestSubAgentTodoList: (subTaskId: string) => Command;
requestChannelList: () => Command;
}
export function useSideData(): UseSideDataReturn {
const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [channels, setChannels] = useState<Channel[]>([])
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
const [memories, setMemories] = useState<MemorySummary[]>([]);
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [todos, setTodos] = useState<TodoItemSummary[]>([]);
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
const [channels, setChannels] = useState<Channel[]>([]);
const [selectedChannel, setSelectedChannel] = useState<string>('websocket');
const isWritable = useMemo(
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
[channels, selectedChannel]
)
() => channels.find((c) => c.id === selectedChannel)?.isWritable ?? false,
[channels, selectedChannel],
);
const requestMemoryList = useCallback((): Command => {
return { type: 'list_memories' }
}, [])
return { type: 'list_memories' };
}, []);
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 => {
return { type: 'update_memory', id, content }
}, [])
return { type: 'update_memory', id, content };
}, []);
const deleteMemory = useCallback((id: string): Command => {
return { type: 'delete_memory', id }
}, [])
return { type: 'delete_memory', id };
}, []);
const requestSkillList = useCallback((): Command => {
return { type: 'list_skills' }
}, [])
return { type: 'list_skills' };
}, []);
const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' }
}, [])
return { type: 'list_todos' };
}, []);
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
return { type: 'list_todos', task_id: subTaskId }
}, [])
return { type: 'list_todos', task_id: subTaskId };
}, []);
const requestChannelList = useCallback((): Command => {
return { type: 'list_channels' }
}, [])
return { type: 'list_channels' };
}, []);
return {
memories,
@ -100,5 +100,5 @@ export function useSideData(): UseSideDataReturn {
requestTodoList,
requestSubAgentTodoList,
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 {
ChatMessage,
WsOutbound,
@ -8,370 +17,413 @@ import type {
TaskStarted,
TaskMessagesLoaded,
Command,
} from '../../types/protocol'
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers'
import type { SubAgentView } from './types'
} from '../../types/protocol';
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers';
import type { SubAgentView } from './types';
interface UseSubAgentViewOptions {
/** 发送命令到后端(用于子代理 todo_write 后刷新待办) */
sendCommand: (cmd: Command) => void
sendCommand: (cmd: Command) => void;
/** 构建子代理待办刷新命令 */
requestSubAgentTodoList: (subTaskId: string) => Command
requestSubAgentTodoList: (subTaskId: string) => Command;
}
export interface UseSubAgentViewReturn {
subAgentStack: SubAgentView[]
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>
subAgentView: SubAgentView | null
subAgentViewRef: MutableRefObject<SubAgentView | null>
subAgentStackRef: MutableRefObject<SubAgentView[]>
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
exitSubAgentView: () => Command | null
navigateToSubAgentLevel: (index: number) => Command | null
subAgentStack: SubAgentView[];
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>;
subAgentView: SubAgentView | null;
subAgentViewRef: MutableRefObject<SubAgentView | null>;
subAgentStackRef: MutableRefObject<SubAgentView[]>;
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
exitSubAgentView: () => Command | null;
navigateToSubAgentLevel: (index: number) => Command | null;
/** 处理子智能体视图的消息路由Tier 2返回是否已处理 */
handleSubAgentMessage: (message: WsOutbound) => boolean
handleSubAgentMessage: (message: WsOutbound) => boolean;
}
export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn {
const { sendCommand, requestSubAgentTodoList } = options
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
const { sendCommand, requestSubAgentTodoList } = options;
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([]);
const subAgentView = useMemo(
() => (subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null),
[subAgentStack],
);
const subAgentViewRef = useRef<SubAgentView | null>(null)
const subAgentStackRef = useRef<SubAgentView[]>([])
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
const subAgentViewRef = useRef<SubAgentView | null>(null);
const subAgentStackRef = useRef<SubAgentView[]>([]);
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
// ref 同步:确保回调中读到最新值
useEffect(() => {
subAgentViewRef.current = subAgentView
}, [subAgentView])
subAgentViewRef.current = subAgentView;
}, [subAgentView]);
useEffect(() => {
subAgentStackRef.current = subAgentStack
}, [subAgentStack])
subAgentStackRef.current = subAgentStack;
}, [subAgentStack]);
// 追加消息到栈顶视图(含流式累加)
const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => {
// stream_delta: accumulate into existing message by ID, or create new
if (message.type === 'stream_delta') {
const msg = message as StreamDelta
const msg = message as StreamDelta;
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
if (existingIdx >= 0) {
const updated = [...top.messages]
const existing = updated[existingIdx]
const updated = [...top.messages];
const existing = updated[existingIdx];
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
};
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updated };
return newStack;
}
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
return
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack;
});
return;
}
// stream_end: no-op, assistant_response will replace
if (message.type === 'stream_end') return
if (message.type === 'stream_end') return;
// execution_completed: 更新栈顶 status 为 completed
if (message.type === 'execution_completed') {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, status: 'completed' }
return newStack
})
return
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, status: 'completed' };
return newStack;
});
return;
}
// error: 更新栈顶 status 为 error并追加错误消息
if (message.type === 'error') {
const errMsg = message as WsError
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
};
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, status: 'error', messages: [...top.messages, errorChatMsg] }
return newStack
})
return
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
const newStack = [...prev];
newStack[newStack.length - 1] = {
...top,
status: 'error',
messages: [...top.messages, errorChatMsg],
};
return newStack;
});
return;
}
// Other messages: assistant_response replaces streamed message by ID
const chatMsg = serverMessageToChatMessage(message)
const chatMsg = serverMessageToChatMessage(message);
if (chatMsg) {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
if (prev.length === 0) return prev;
const top = prev[prev.length - 1];
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) {
const updated = [...top.messages]
updated[existingIdx] = chatMsg
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updated }
return newStack
const updated = [...top.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: updated };
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 并发调用导致重复。
const exists = top.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type)
if (exists) return prev
const exists = top.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
if (exists) return prev;
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
return newStack
})
const newStack = [...prev];
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
return newStack;
});
}
}, [])
}, []);
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
setSubAgentStack((prev) => {
const idx = prev.findIndex(v => v.taskId === taskId)
if (idx < 0) return prev
const layer = prev[idx]
const idx = prev.findIndex((v) => v.taskId === taskId);
if (idx < 0) return prev;
const layer = prev[idx];
if (message.type === 'execution_completed') {
const newStack = [...prev]
newStack[idx] = { ...layer, status: 'completed' }
return newStack
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'completed' };
return newStack;
}
if (message.type === 'error') {
const errMsg = message as WsError
const errMsg = message as WsError;
const errorChatMsg: ChatMessage = {
id: generateMessageId(),
role: 'assistant',
content: `Error: ${errMsg.message}`,
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
type: 'message',
}
const newStack = [...prev]
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] }
return newStack
};
const newStack = [...prev];
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
return newStack;
}
if (message.type === 'stream_delta') {
const msg = message as StreamDelta
const existingIdx = layer.messages.findIndex(m => m.id === msg.id && m.type === 'message')
const msg = message as StreamDelta;
const existingIdx = layer.messages.findIndex(
(m) => m.id === msg.id && m.type === 'message',
);
if (existingIdx >= 0) {
const updated = [...layer.messages]
const existing = updated[existingIdx]
const updated = [...layer.messages];
const existing = updated[existingIdx];
updated[existingIdx] = {
...existing,
content: existing.content + msg.delta,
reasoningContent: msg.reasoning_delta
? (existing.reasoningContent || '') + msg.reasoning_delta
: existing.reasoningContent,
}
const newStack = [...prev]
newStack[idx] = { ...layer, messages: updated }
return newStack
};
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
const newStack = [...prev]
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
return newStack
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack;
}
if (message.type === 'stream_end') return prev
const chatMsg = serverMessageToChatMessage(message)
if (!chatMsg) return prev
if (message.type === 'stream_end') return prev;
const chatMsg = serverMessageToChatMessage(message);
if (!chatMsg) return prev;
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) {
const updated = [...layer.messages]
updated[existingIdx] = chatMsg
const newStack = [...prev]
newStack[idx] = { ...layer, messages: updated }
return newStack
const updated = [...layer.messages];
updated[existingIdx] = chatMsg;
const newStack = [...prev];
newStack[idx] = { ...layer, messages: updated };
return newStack;
}
} else if (message.type === 'tool_call' || 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
} else if (
message.type === 'tool_call' ||
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]
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
return newStack
})
}, [])
const newStack = [...prev];
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
return newStack;
});
}, []);
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: subagentType || '',
status: 'loading',
messages: [],
}
// 同步设置 ref消除竞态窗口
subAgentViewRef.current = newView
subAgentStackRef.current = [...subAgentStackRef.current, newView]
setSubAgentStack((prev) => [...prev, newView])
return { type: 'load_task_messages', task_id: taskId }
}, [])
const enterSubAgentView = useCallback(
(taskId: string, description: string, subagentType?: string): Command => {
const newView: SubAgentView = {
taskId,
description,
subagentType: subagentType || '',
status: 'loading',
messages: [],
};
// 同步设置 ref消除竞态窗口
subAgentViewRef.current = newView;
subAgentStackRef.current = [...subAgentStackRef.current, newView];
setSubAgentStack((prev) => [...prev, newView]);
return { type: 'load_task_messages', task_id: taskId };
},
[],
);
const exitSubAgentView = useCallback((): Command | null => {
const current = subAgentStackRef.current
const current = subAgentStackRef.current;
if (current.length <= 1) {
subAgentViewRef.current = null
subAgentStackRef.current = []
setSubAgentStack([])
return null
subAgentViewRef.current = null;
subAgentStackRef.current = [];
setSubAgentStack([]);
return null;
}
const newStack = current.slice(0, -1)
const newTop = newStack[newStack.length - 1]
subAgentViewRef.current = newTop
const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
subAgentStackRef.current = clearedStack
setSubAgentStack(clearedStack)
return { type: 'load_task_messages', task_id: newTop.taskId }
}, [])
const newStack = current.slice(0, -1);
const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop;
const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId };
}, []);
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
const current = subAgentStackRef.current
const current = subAgentStackRef.current;
if (index < 0) {
subAgentViewRef.current = null
subAgentStackRef.current = []
setSubAgentStack([])
return null
subAgentViewRef.current = null;
subAgentStackRef.current = [];
setSubAgentStack([]);
return null;
}
if (index >= current.length) return null
const newStack = current.slice(0, index + 1)
const newTop = newStack[newStack.length - 1]
subAgentViewRef.current = newTop
const clearedStack = [...newStack]
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
subAgentStackRef.current = clearedStack
setSubAgentStack(clearedStack)
return { type: 'load_task_messages', task_id: newTop.taskId }
}, [])
if (index >= current.length) return null;
const newStack = current.slice(0, index + 1);
const newTop = newStack[newStack.length - 1];
subAgentViewRef.current = newTop;
const clearedStack = [...newStack];
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
subAgentStackRef.current = clearedStack;
setSubAgentStack(clearedStack);
return { type: 'load_task_messages', task_id: newTop.taskId };
}, []);
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
const handleSubAgentMessage = useCallback((message: WsOutbound): boolean => {
const currentSubAgentView = subAgentViewRef.current
if (!currentSubAgentView) return false
const handleSubAgentMessage = useCallback(
(message: WsOutbound): boolean => {
const currentSubAgentView = subAgentViewRef.current;
if (!currentSubAgentView) return false;
if (message.type === 'task_messages_loaded') {
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
if (message.type === 'task_messages_loaded') {
const msg = message as TaskMessagesLoaded;
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
if (msg.tool_call_id) {
const idx = updatedMessages.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
matched = true
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
}
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
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;
}
}
const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message)
if (message.type === 'task_started') {
const msg = message as TaskStarted;
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 (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
if (msg.tool_call_id) {
const idx = updatedMessages.findIndex(
(m) =>
m.toolCallId === msg.tool_call_id &&
m.type === 'tool_call' &&
m.toolName === 'task',
);
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id };
matched = true;
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 完成后自动刷新待办列表
if (message.type === 'tool_result' && (message as { tool_name: string }).tool_name === 'todo_write') {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
sendCommand(refreshCmd)
const msgSubagentTaskId = getSubagentTaskId(message);
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message);
// 检查 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
if (msgSubagentTaskId) {
appendToSubAgentLayerMessage(msgSubagentTaskId, message)
return true
}
// 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId
if (msgSubagentTaskId) {
appendToSubAgentLayerMessage(msgSubagentTaskId, message);
return true;
}
// 消息不属于子智能体路由fall through 到主视图
return false
}, [appendToSubAgentViewMessage, appendToSubAgentLayerMessage, sendCommand, requestSubAgentTodoList])
// 消息不属于子智能体路由fall through 到主视图
return false;
},
[
appendToSubAgentViewMessage,
appendToSubAgentLayerMessage,
sendCommand,
requestSubAgentTodoList,
],
);
return {
subAgentStack,
@ -383,5 +435,5 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
exitSubAgentView,
navigateToSubAgentLevel,
handleSubAgentMessage,
}
};
}

View File

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

View File

@ -1,6 +1,6 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useChat } from './useChat'
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useChat } from './useChat';
import type {
WsInbound,
SessionEstablished,
@ -28,27 +28,27 @@ import type {
SchedulerJobSummary,
SchedulerJobSessionLookup,
ExecutionCancelled,
} from '../types/protocol'
} from '../types/protocol';
// ---- helpers ----
function renderUseChat() {
const sendMessage = vi.fn((_msg: WsInbound) => true)
const { result } = renderHook(() => useChat())
const sendMessage = vi.fn((_msg: WsInbound) => true);
const { result } = renderHook(() => useChat());
act(() => {
result.current.setSendMessage(sendMessage)
})
return { result, sendMessage }
result.current.setSendMessage(sendMessage);
});
return { result, sendMessage };
}
/** 取出 sendMessage 收到的最后一条 command payload已 JSON.parse */
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
const calls = sendMessage.mock.calls
const last = calls.length > 0 ? calls[calls.length - 1][0] as WsInbound : undefined
const calls = sendMessage.mock.calls;
const last = calls.length > 0 ? (calls[calls.length - 1][0] as WsInbound) : undefined;
if (last && last.type === 'command') {
return JSON.parse(last.payload)
return JSON.parse(last.payload);
}
return undefined
return undefined;
}
// ---- fixtures ----
@ -56,7 +56,7 @@ function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
const sessionEstablished: SessionEstablished = {
type: 'session_established',
session_id: 'sess-1',
}
};
function makeSession(id: string): SessionSummary {
return {
@ -66,13 +66,13 @@ function makeSession(id: string): SessionSummary {
chat_id: `chat-${id}`,
message_count: 0,
last_active_at: 1000,
}
};
}
const sessionList: SessionList = {
type: 'session_list',
sessions: [makeSession('s1'), makeSession('s2')],
}
};
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
return {
@ -82,32 +82,32 @@ function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
message_count: 0,
created_at: 1000,
last_active_at: 2000,
}
};
}
const topicList: TopicList = {
type: 'topic_list',
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1',
}
};
const streamDelta1: StreamDelta = {
type: 'stream_delta',
id: 'm1',
delta: 'Hello',
}
};
const streamDelta2: StreamDelta = {
type: 'stream_delta',
id: 'm1',
delta: ' world',
}
};
const assistantResponse: AssistantResponse = {
type: 'assistant_response',
id: 'm1',
content: 'Hello world',
role: 'assistant',
}
};
const toolCall: ToolCall = {
type: 'tool_call',
@ -117,7 +117,7 @@ const toolCall: ToolCall = {
arguments: { x: 1 },
content: 'calling calculator',
role: 'tool',
}
};
const toolResult: ToolResult = {
type: 'tool_result',
@ -126,7 +126,7 @@ const toolResult: ToolResult = {
tool_name: 'calculator',
content: '42',
role: 'tool',
}
};
const toolPending: ToolPending = {
type: 'tool_pending',
@ -136,38 +136,45 @@ const toolPending: ToolPending = {
content: 'waiting',
resume_hint: 'resume later',
role: 'tool',
}
};
const errorMsg: WsError = {
type: 'error',
code: 'ERR',
message: 'something broke',
}
};
const executionCancelled: ExecutionCancelled = {
type: 'execution_cancelled',
message: 'stopped by user',
}
};
const memoryList: MemoryList = {
type: 'memory_list',
memories: [
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
] as MemorySummary[],
}
};
const skillList: SkillList = {
type: 'skill_list',
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
}
};
const todoList: TodoList = {
type: 'todo_list',
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[],
scope_key: 'main',
}
};
const channelList: ChannelList = {
type: 'channel_list',
@ -175,127 +182,135 @@ const channelList: ChannelList = {
{ id: 'websocket', name: 'WebSocket', isWritable: true },
{ id: 'cli', name: 'CLI', isWritable: false },
] as Channel[],
}
};
const schedulerJobList: SchedulerJobList = {
type: 'scheduler_job_list',
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 ----
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
describe('useChat - handleServerMessage characterization', () => {
it('1. session_established sets connectionId and isConnected', () => {
const { result } = renderUseChat()
expect(result.current.isConnected).toBe(false)
act(() => result.current.handleServerMessage(sessionEstablished))
expect(result.current.connectionId).toBe('sess-1')
expect(result.current.isConnected).toBe(true)
})
const { result } = renderUseChat();
expect(result.current.isConnected).toBe(false);
act(() => result.current.handleServerMessage(sessionEstablished));
expect(result.current.connectionId).toBe('sess-1');
expect(result.current.isConnected).toBe(true);
});
it('2. session_list fills sessions and auto-selects the first', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(sessionList))
expect(result.current.sessions).toHaveLength(2)
expect(result.current.selectedSessionId).toBe('s1')
expect(result.current.session?.session_id).toBe('s1')
})
const { result } = renderUseChat();
act(() => result.current.handleServerMessage(sessionList));
expect(result.current.sessions).toHaveLength(2);
expect(result.current.selectedSessionId).toBe('s1');
expect(result.current.session?.session_id).toBe('s1');
});
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
act(() => result.current.handleServerMessage(sessionEstablished))
act(() => result.current.handleServerMessage(sessionList))
act(() => result.current.handleServerMessage(sessionEstablished));
act(() => result.current.handleServerMessage(sessionList));
// first topic_list (without createTopic) sets topics but does NOT auto-select
act(() => result.current.handleServerMessage(topicList))
expect(result.current.topics).toHaveLength(2)
expect(result.current.selectedTopic).toBeNull()
act(() => result.current.handleServerMessage(topicList));
expect(result.current.topics).toHaveLength(2);
expect(result.current.selectedTopic).toBeNull();
// 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 = {
type: 'topic_list',
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
session_id: 's1',
}
act(() => result.current.handleServerMessage(newTopicList))
expect(result.current.selectedTopic).toBe('t3')
})
};
act(() => result.current.handleServerMessage(newTopicList));
expect(result.current.selectedTopic).toBe('t3');
});
it('4. stream_delta creates a message then accumulates into it by id', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(streamDelta1))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello')
act(() => result.current.handleServerMessage(streamDelta2))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello world')
})
const { result } = renderUseChat();
act(() => result.current.handleServerMessage(streamDelta1));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello');
act(() => result.current.handleServerMessage(streamDelta2));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello world');
});
it('5. assistant_response replaces the streamed message by id', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(streamDelta1))
act(() => result.current.handleServerMessage(streamDelta2))
act(() => result.current.handleServerMessage(assistantResponse))
expect(result.current.messages).toHaveLength(1)
expect(result.current.messages[0].content).toBe('Hello world')
expect(result.current.messages[0].id).toBe('m1')
})
const { result } = renderUseChat();
act(() => result.current.handleServerMessage(streamDelta1));
act(() => result.current.handleServerMessage(streamDelta2));
act(() => result.current.handleServerMessage(assistantResponse));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe('Hello world');
expect(result.current.messages[0].id).toBe('m1');
});
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(toolCall))
act(() => result.current.handleServerMessage(toolResult))
act(() => result.current.handleServerMessage(toolPending))
expect(result.current.messages).toHaveLength(3)
expect(result.current.messages[0].type).toBe('tool_call')
expect(result.current.messages[0].toolName).toBe('calculator')
expect(result.current.messages[1].type).toBe('tool_result')
expect(result.current.messages[2].type).toBe('tool_pending')
expect(result.current.messages[2].content).toContain('resume later')
})
const { result } = renderUseChat();
act(() => result.current.handleServerMessage(toolCall));
act(() => result.current.handleServerMessage(toolResult));
act(() => result.current.handleServerMessage(toolPending));
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].type).toBe('tool_call');
expect(result.current.messages[0].toolName).toBe('calculator');
expect(result.current.messages[1].type).toBe('tool_result');
expect(result.current.messages[2].type).toBe('tool_pending');
expect(result.current.messages[2].content).toContain('resume later');
});
it('7. error and execution_cancelled append a message and clear isLoading', () => {
const { result } = renderUseChat()
const { result } = renderUseChat();
// set isLoading true via handleMessage
act(() => result.current.handleMessage('hi'))
expect(result.current.isLoading).toBe(true)
act(() => result.current.handleServerMessage(errorMsg))
expect(result.current.isLoading).toBe(false)
const errMsg = result.current.messages[result.current.messages.length - 1]
expect(errMsg?.content).toBe('Error: something broke')
act(() => result.current.handleMessage('hi'));
expect(result.current.isLoading).toBe(true);
act(() => result.current.handleServerMessage(errorMsg));
expect(result.current.isLoading).toBe(false);
const errMsg = result.current.messages[result.current.messages.length - 1];
expect(errMsg?.content).toBe('Error: something broke');
// reset isLoading + cleared, then test execution_cancelled
act(() => result.current.handleMessage('hi again'))
expect(result.current.isLoading).toBe(true)
act(() => result.current.handleServerMessage(executionCancelled))
expect(result.current.isLoading).toBe(false)
const cancelMsg = result.current.messages[result.current.messages.length - 1]
expect(cancelMsg?.content).toBe('stopped by user')
})
act(() => result.current.handleMessage('hi again'));
expect(result.current.isLoading).toBe(true);
act(() => result.current.handleServerMessage(executionCancelled));
expect(result.current.isLoading).toBe(false);
const cancelMsg = result.current.messages[result.current.messages.length - 1];
expect(cancelMsg?.content).toBe('stopped by user');
});
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
const { result } = renderUseChat()
act(() => result.current.handleServerMessage(memoryList))
act(() => result.current.handleServerMessage(skillList))
act(() => result.current.handleServerMessage(todoList))
act(() => result.current.handleServerMessage(channelList))
act(() => result.current.handleServerMessage(schedulerJobList))
expect(result.current.memories).toHaveLength(1)
expect(result.current.skills).toHaveLength(1)
expect(result.current.todos).toHaveLength(1)
expect(result.current.channels).toHaveLength(2)
expect(result.current.schedulerJobs).toHaveLength(1)
})
const { result } = renderUseChat();
act(() => result.current.handleServerMessage(memoryList));
act(() => result.current.handleServerMessage(skillList));
act(() => result.current.handleServerMessage(todoList));
act(() => result.current.handleServerMessage(channelList));
act(() => result.current.handleServerMessage(schedulerJobList));
expect(result.current.memories).toHaveLength(1);
expect(result.current.skills).toHaveLength(1);
expect(result.current.todos).toHaveLength(1);
expect(result.current.channels).toHaveLength(2);
expect(result.current.schedulerJobs).toHaveLength(1);
});
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
const { result } = renderUseChat()
const { result } = renderUseChat();
const taskToolCall: ToolCall = {
type: 'tool_call',
id: 'tc-task',
@ -304,9 +319,11 @@ describe('useChat - handleServerMessage characterization', () => {
arguments: { prompt: 'do sub' },
content: 'spawning sub',
role: 'tool',
}
act(() => result.current.handleServerMessage(taskToolCall))
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBeUndefined()
};
act(() => result.current.handleServerMessage(taskToolCall));
expect(
result.current.messages[result.current.messages.length - 1]?.navigateToTaskId,
).toBeUndefined();
const taskStarted: TaskStarted = {
type: 'task_started',
@ -314,16 +331,18 @@ describe('useChat - handleServerMessage characterization', () => {
description: 'sub agent',
subagent_type: 'general',
tool_call_id: 'tc-task',
}
act(() => result.current.handleServerMessage(taskStarted))
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe('sub-1')
})
};
act(() => result.current.handleServerMessage(taskStarted));
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', () => {
const { result } = renderUseChat()
const { result } = renderUseChat();
// enter sub-agent view for task "sub-1"
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'))
expect(result.current.subAgentView?.taskId).toBe('sub-1')
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'));
expect(result.current.subAgentView?.taskId).toBe('sub-1');
// task_messages_loaded updates top metadata
const loaded: TaskMessagesLoaded = {
@ -333,10 +352,10 @@ describe('useChat - handleServerMessage characterization', () => {
subagent_type: 'general',
status: 'running',
summary: 'working',
}
act(() => result.current.handleServerMessage(loaded))
expect(result.current.subAgentView?.status).toBe('running')
expect(result.current.subAgentView?.summary).toBe('working')
};
act(() => result.current.handleServerMessage(loaded));
expect(result.current.subAgentView?.status).toBe('running');
expect(result.current.subAgentView?.summary).toBe('working');
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
const subStream: StreamDelta = {
@ -344,33 +363,33 @@ describe('useChat - handleServerMessage characterization', () => {
id: 'sub-m1',
delta: 'sub hello',
subagent_task_id: 'sub-1',
}
act(() => result.current.handleServerMessage(subStream))
expect(result.current.subAgentView?.messages).toHaveLength(1)
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello')
};
act(() => result.current.handleServerMessage(subStream));
expect(result.current.subAgentView?.messages).toHaveLength(1);
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello');
// exit back to main: main messages should not contain the sub-agent message
act(() => result.current.exitSubAgentView())
expect(result.current.messages.find(m => m.id === 'sub-m1')).toBeUndefined()
})
act(() => result.current.exitSubAgentView());
expect(result.current.messages.find((m) => m.id === 'sub-m1')).toBeUndefined();
});
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
const { result } = renderUseChat()
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' }
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'))
expect(result.current.schedulerView).not.toBeNull()
const { result } = renderUseChat();
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' };
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'));
expect(result.current.schedulerView).not.toBeNull();
act(() => result.current.handleServerMessage(assistantResponse))
expect(result.current.schedulerView?.messages).toHaveLength(1)
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world')
act(() => result.current.handleServerMessage(assistantResponse));
expect(result.current.schedulerView?.messages).toHaveLength(1);
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world');
// exit scheduler view: main messages should not contain the routed message
act(() => result.current.exitSchedulerJobView())
expect(result.current.messages.find(m => m.id === 'm1')).toBeUndefined()
})
act(() => result.current.exitSchedulerJobView());
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', () => {
const { result, sendMessage } = renderUseChat()
const { result, sendMessage } = renderUseChat();
const todoWriteResult: ToolResult = {
type: 'tool_result',
id: 'tr-todo',
@ -378,30 +397,30 @@ describe('useChat - handleServerMessage characterization', () => {
tool_name: 'todo_write',
content: 'updated',
role: 'tool',
}
act(() => result.current.handleServerMessage(todoWriteResult))
const cmd = lastCommand(sendMessage)
expect(cmd).toEqual({ type: 'list_todos' })
})
};
act(() => result.current.handleServerMessage(todoWriteResult));
const cmd = lastCommand(sendMessage);
expect(cmd).toEqual({ type: 'list_todos' });
});
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
const { result } = renderUseChat()
const { result } = renderUseChat();
act(() => {
result.current.handleServerMessage(sessionEstablished)
result.current.handleServerMessage(sessionList)
result.current.handleServerMessage(topicList)
})
result.current.handleServerMessage(sessionEstablished);
result.current.handleServerMessage(sessionList);
result.current.handleServerMessage(topicList);
});
// topic_list without createTopic does NOT auto-select; manually select t1
act(() => result.current.selectTopic('t1'))
expect(result.current.selectedTopic).toBe('t1')
act(() => result.current.selectTopic('t1'));
expect(result.current.selectedTopic).toBe('t1');
const otherTopicStream: StreamDelta = {
type: 'stream_delta',
id: 'm-other',
delta: 'should be dropped',
topic_id: 't-other',
}
act(() => result.current.handleServerMessage(otherTopicStream))
expect(result.current.messages.find(m => m.id === 'm-other')).toBeUndefined()
})
})
};
act(() => result.current.handleServerMessage(otherTopicStream));
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 {
Command,
ChatMessage,
@ -13,201 +13,207 @@ import type {
SchedulerJobSummary,
SchedulerJobSessionLookup,
Channel,
} from '../types/protocol'
import type { SubAgentView, SchedulerJobView } from './chat/types'
import { getSubagentTaskId } from './chat/messageMappers'
import { useConnection } from './chat/useConnection'
import { useSideData } from './chat/useSideData'
import { useSessions } from './chat/useSessions'
import { useTopics } from './chat/useTopics'
import { useMessages } from './chat/useMessages'
import { useSubAgentView } from './chat/useSubAgentView'
import { useSchedulerView } from './chat/useSchedulerView'
} from '../types/protocol';
import type { SubAgentView, SchedulerJobView } from './chat/types';
import { getSubagentTaskId } from './chat/messageMappers';
import { useConnection } from './chat/useConnection';
import { useSideData } from './chat/useSideData';
import { useSessions } from './chat/useSessions';
import { useTopics } from './chat/useTopics';
import { useMessages } from './chat/useMessages';
import { useSubAgentView } from './chat/useSubAgentView';
import { useSchedulerView } from './chat/useSchedulerView';
// 简化后的层级状态
interface UseChatReturn {
// 连接状态
connectionId: string | null
isConnected: boolean
connectionId: string | null;
isConnected: boolean;
// 简化的层级状态
sessions: SessionSummary[]
selectedSessionId: string | null
session: SessionSummary | null
sessionId: string | null
chatId: string
topics: Topic[]
selectedTopic: string | null
sessions: SessionSummary[];
selectedSessionId: string | null;
session: SessionSummary | null;
sessionId: string | null;
chatId: string;
topics: Topic[];
selectedTopic: string | null;
// 消息
messages: ChatMessage[]
isLoading: boolean
messages: ChatMessage[];
isLoading: boolean;
// 通道状态
channels: Channel[]
selectedChannel: string
isWritable: boolean
channels: Channel[];
selectedChannel: string;
isWritable: boolean;
// 是否只读
isReadOnly: boolean
isReadOnly: boolean;
// 子智能体视图(栈结构,支持面包屑导航)
subAgentView: SubAgentView | null
subAgentStack: SubAgentView[]
subAgentView: SubAgentView | null;
subAgentStack: SubAgentView[];
// 方法
handleMessage: (content: string, attachments?: Attachment[]) => void
handleCommand: (command: Command) => void
clearMessages: () => void
handleServerMessage: (message: WsOutbound) => void
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
handleMessage: (content: string, attachments?: Attachment[]) => void;
handleCommand: (command: Command) => void;
clearMessages: () => void;
handleServerMessage: (message: WsOutbound) => void;
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
// Topic 方法
selectTopic: (topicId: string) => void
createTopic: (title?: string) => Command
switchTopic: (topicId: string) => Command
deleteTopic: (topicId: string) => Command
renameTopic: (topicId: string, title: string) => Command
selectTopic: (topicId: string) => void;
createTopic: (title?: string) => Command;
switchTopic: (topicId: string) => Command;
deleteTopic: (topicId: string) => Command;
renameTopic: (topicId: string, title: string) => Command;
// 初始化方法
requestSessionList: () => Command
requestTopicList: () => Command | null
topicRefreshTrigger: number
requestChannelList: () => Command
selectChannel: (channelId: string) => void
selectSession: (sessionId: string) => void
requestSessionList: () => Command;
requestTopicList: () => Command | null;
topicRefreshTrigger: number;
requestChannelList: () => Command;
selectChannel: (channelId: string) => void;
selectSession: (sessionId: string) => void;
// 子智能体导航方法
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
exitSubAgentView: () => Command | null
navigateToSubAgentLevel: (index: number) => Command | null
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
exitSubAgentView: () => Command | null;
navigateToSubAgentLevel: (index: number) => Command | null;
// 记忆状态
memories: MemorySummary[]
requestMemoryList: () => Command
createMemory: (namespace: string, key: string, content: string) => Command
updateMemory: (id: string, content: string) => Command
deleteMemory: (id: string) => Command
memories: MemorySummary[];
requestMemoryList: () => Command;
createMemory: (namespace: string, key: string, content: string) => Command;
updateMemory: (id: string, content: string) => Command;
deleteMemory: (id: string) => Command;
// 技能状态
skills: SkillSummary[]
requestSkillList: () => Command
skills: SkillSummary[];
requestSkillList: () => Command;
// Todo 状态
todos: TodoItemSummary[]
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command
todos: TodoItemSummary[];
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
requestTodoList: () => Command;
requestSubAgentTodoList: (subTaskId: string) => Command;
// 高亮消息 ID点击待办后滚动到对应消息
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
highlightedMessageId: string | null;
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
// 定时任务状态
schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler'
setSidebarTab: (tab: 'topics' | 'scheduler') => void
requestSchedulerJobList: () => Command
schedulerJobs: SchedulerJobSummary[];
sidebarTab: 'topics' | 'scheduler';
setSidebarTab: (tab: 'topics' | 'scheduler') => void;
requestSchedulerJobList: () => Command;
// 定时任务执行对话查看
schedulerView: SchedulerJobView | null
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
exitSchedulerJobView: () => void
schedulerView: SchedulerJobView | null;
enterSchedulerJobView: (
lookup: SchedulerJobSessionLookup,
jobId: string,
description: string,
) => Command;
exitSchedulerJobView: () => void;
// 停止当前 Agent 执行
handleStop: () => Command
handleStop: () => Command;
}
export function useChat(): UseChatReturn {
// 调用顺序确保依赖方向useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList
const conn = useConnection()
const sideData = useSideData()
const sessions = useSessions()
const topics = useTopics()
const conn = useConnection();
const sideData = useSideData();
const sessions = useSessions();
const topics = useTopics();
const messages = useMessages({
selectedTopicRef: topics.selectedTopicRef,
topicsRef: topics.topicsRef,
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
})
});
const subAgent = useSubAgentView({
sendCommand: conn.sendCommand,
requestSubAgentTodoList: sideData.requestSubAgentTodoList,
})
const scheduler = useSchedulerView()
});
const scheduler = useSchedulerView();
// ---- handleServerMessage: 纯路由分发Tier 1 → Tier 2 → Tier 3 ----
// 所有被调用的方法都是稳定引用useState setter 或 useCallback([])
// 因此空依赖数组安全,不会产生过期闭包。
const handleServerMessage = useCallback((message: WsOutbound) => {
// Tier 1: 调度器视图激活时chat 消息路由到 schedulerView
if (scheduler.handleSchedulerMessage(message)) return
if (scheduler.handleSchedulerMessage(message)) return;
// Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack
if (subAgent.handleSubAgentMessage(message)) return
if (subAgent.handleSubAgentMessage(message)) return;
// Tier 3: 主视图路由
// 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中)
if (getSubagentTaskId(message)) return
if (getSubagentTaskId(message)) return;
// 3b: 非 chat 消息的 case 分发
switch (message.type) {
case 'session_established':
conn.setConnectionId(message.session_id)
return
conn.setConnectionId(message.session_id);
return;
case 'session_list':
// 清空旧数据(切换通道时避免数据污染)
topics.setTopics([])
topics.setSelectedTopic(null)
messages.setMessages([])
sessions.setSessions(message.sessions)
topics.setTopics([]);
topics.setSelectedTopic(null);
messages.setMessages([]);
sessions.setSessions(message.sessions);
// 自动选中:优先保持当前选中,否则选第一个
sessions.setSelectedSessionId(prev =>
prev && message.sessions.some(s => s.session_id === prev)
sessions.setSelectedSessionId((prev) =>
prev && message.sessions.some((s) => s.session_id === prev)
? prev
: message.sessions.length > 0 ? message.sessions[0].session_id : null
)
messages.setIsLoading(false)
return
: message.sessions.length > 0
? message.sessions[0].session_id
: null,
);
messages.setIsLoading(false);
return;
case 'session_created':
case 'session_loaded':
messages.setIsLoading(false)
return
messages.setIsLoading(false);
return;
case 'topic_list': {
const autoFocused = topics.handleTopicList(message)
if (autoFocused) messages.setMessages([])
messages.setIsLoading(false)
return
const autoFocused = topics.handleTopicList(message);
if (autoFocused) messages.setMessages([]);
messages.setIsLoading(false);
return;
}
case 'topic_renamed':
topics.handleTopicRenamed(message)
return
topics.handleTopicRenamed(message);
return;
case 'scheduler_job_list':
scheduler.setSchedulerJobs(message.jobs)
return
scheduler.setSchedulerJobs(message.jobs);
return;
case 'memory_list':
sideData.setMemories(message.memories)
return
sideData.setMemories(message.memories);
return;
case 'skill_list':
sideData.setSkills(message.skills)
return
sideData.setSkills(message.skills);
return;
case 'todo_list':
sideData.setTodos(message.todos)
return
sideData.setTodos(message.todos);
return;
case 'channel_list':
sideData.setChannels(message.channels)
return
sideData.setChannels(message.channels);
return;
case 'pong':
return
return;
default:
// 3c: chat 类消息task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled
@ -216,17 +222,20 @@ export function useChat(): UseChatReturn {
// 注意topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true
// 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数,
// 因此这里需要再次检查 topic_id 以保持行为等价。
if (message.type === 'tool_result' && message.tool_name === 'todo_write'
&& (!message.topic_id || message.topic_id === topics.selectedTopicRef.current)) {
if (
message.type === 'tool_result' &&
message.tool_name === 'todo_write' &&
(!message.topic_id || message.topic_id === topics.selectedTopicRef.current)
) {
const cmd = subAgent.subAgentViewRef.current?.taskId
? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId)
: sideData.requestTodoList()
conn.sendCommand(cmd)
: sideData.requestTodoList();
conn.sendCommand(cmd);
}
}
return
return;
}
}, [])
}, []);
// ---- handleCommand: 根据命令类型设置 loading 状态 ----
const handleCommand = useCallback((command: Command) => {
@ -238,70 +247,76 @@ export function useChat(): UseChatReturn {
case 'list_sessions_by_channel':
case 'delete_topic':
case 'list_topics':
messages.setIsLoading(true)
break
messages.setIsLoading(true);
break;
}
}, [])
}, []);
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
const selectTopic = useCallback((topicId: string) => {
topics.setSelectedTopic(topicId)
messages.setMessages([])
topics.setSelectedTopic(topicId);
messages.setMessages([]);
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
subAgent.subAgentViewRef.current = null
subAgent.subAgentStackRef.current = []
subAgent.setSubAgentStack([])
}, [])
subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = [];
subAgent.setSubAgentStack([]);
}, []);
// ---- selectChannel: 切换通道,清空全部状态 ----
const selectChannel = useCallback((channelId: string) => {
if (channelId === sideData.selectedChannel) return
sideData.setSelectedChannel(channelId)
sessions.setSessions([])
sessions.setSelectedSessionId(null)
topics.setTopics([])
topics.setSelectedTopic(null)
messages.setMessages([])
subAgent.subAgentViewRef.current = null
subAgent.subAgentStackRef.current = []
subAgent.setSubAgentStack([])
messages.setIsLoading(true)
}, [sideData.selectedChannel])
const selectChannel = useCallback(
(channelId: string) => {
if (channelId === sideData.selectedChannel) return;
sideData.setSelectedChannel(channelId);
sessions.setSessions([]);
sessions.setSelectedSessionId(null);
topics.setTopics([]);
topics.setSelectedTopic(null);
messages.setMessages([]);
subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = [];
subAgent.setSubAgentStack([]);
messages.setIsLoading(true);
},
[sideData.selectedChannel],
);
// ---- selectSession: 切换会话,清空 topics/messages/subAgent ----
const selectSession = useCallback((sessionId: string) => {
if (sessionId === sessions.selectedSessionId) return
sessions.setSelectedSessionId(sessionId)
topics.setTopics([])
topics.setSelectedTopic(null)
messages.setMessages([])
subAgent.subAgentViewRef.current = null
subAgent.subAgentStackRef.current = []
subAgent.setSubAgentStack([])
messages.setIsLoading(true)
}, [sessions.selectedSessionId])
const selectSession = useCallback(
(sessionId: string) => {
if (sessionId === sessions.selectedSessionId) return;
sessions.setSelectedSessionId(sessionId);
topics.setTopics([]);
topics.setSelectedTopic(null);
messages.setMessages([]);
subAgent.subAgentViewRef.current = null;
subAgent.subAgentStackRef.current = [];
subAgent.setSubAgentStack([]);
messages.setIsLoading(true);
},
[sessions.selectedSessionId],
);
// ---- 委托方法 ----
const requestSessionList = useCallback((): Command => {
return sessions.requestSessionList(sideData.selectedChannel)
}, [sideData.selectedChannel])
return sessions.requestSessionList(sideData.selectedChannel);
}, [sideData.selectedChannel]);
const requestTopicList = useCallback((): Command | null => {
return topics.requestTopicList(sessions.sessionId)
}, [sessions.sessionId])
return topics.requestTopicList(sessions.sessionId);
}, [sessions.sessionId]);
const requestChannelList = useCallback((): Command => {
return sideData.requestChannelList()
}, [])
return sideData.requestChannelList();
}, []);
// ---- 派生状态 ----
const resolvedMessages = useMemo(() => {
if (subAgent.subAgentView) return subAgent.subAgentView.messages
if (scheduler.schedulerView) return scheduler.schedulerView.messages
return messages.messages
}, [subAgent.subAgentView, scheduler.schedulerView, messages.messages])
if (subAgent.subAgentView) return subAgent.subAgentView.messages;
if (scheduler.schedulerView) return scheduler.schedulerView.messages;
return messages.messages;
}, [subAgent.subAgentView, scheduler.schedulerView, messages.messages]);
const isReadOnly = !sideData.isWritable
const isReadOnly = !sideData.isWritable;
// ---- 组装返回对象 ----
return {
@ -362,5 +377,5 @@ export function useChat(): UseChatReturn {
enterSchedulerJobView: scheduler.enterSchedulerJobView,
exitSchedulerJobView: scheduler.exitSchedulerJobView,
handleStop: messages.handleStop,
}
};
}

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />