chore(web): prettier 一次性格式化并在 CI 启用 format:check
对 47 个前端文件统一执行 npm run format,消除存量格式差异。 随后在 CI 与 Makefile check 中启用 format:check,确保后续提交强制遵守 prettier 风格。
This commit is contained in:
parent
cda14360af
commit
c724bbf864
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
3
Makefile
3
Makefile
@ -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
|
||||
|
||||
681
web/src/App.tsx
681
web/src/App.tsx
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (协调世界时)' },
|
||||
]
|
||||
];
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
/** 处理主视图的消息类 case(task_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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
);
|
||||
|
||||
@ -1 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
@ -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';
|
||||
|
||||
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
@ -1 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user