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
|
working-directory: web
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Format check (prettier)
|
||||||
|
working-directory: web
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|||||||
3
Makefile
3
Makefile
@ -49,8 +49,9 @@ clean:
|
|||||||
check:
|
check:
|
||||||
@echo "Checking formatting..."
|
@echo "Checking formatting..."
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
@echo "Checking frontend (lint + build)..."
|
@echo "Checking frontend (lint + format + build)..."
|
||||||
cd web && npm run lint
|
cd web && npm run lint
|
||||||
|
cd web && npm run format:check
|
||||||
cd web && npm run build
|
cd web && npm run build
|
||||||
@echo "Checking Rust code..."
|
@echo "Checking Rust code..."
|
||||||
cargo check
|
cargo check
|
||||||
|
|||||||
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',
|
expertsSelect: '/api/experts/select',
|
||||||
sessionSelectModel: '/api/session/select-model',
|
sessionSelectModel: '/api/session/select-model',
|
||||||
sessionSelectedModel: '/api/session/selected-model',
|
sessionSelectedModel: '/api/session/selected-model',
|
||||||
} as const
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基础 fetch 封装:自动添加 JSON headers,解析响应。
|
* 基础 fetch 封装:自动添加 JSON headers,解析响应。
|
||||||
@ -28,7 +28,7 @@ export const API = {
|
|||||||
*/
|
*/
|
||||||
export async function apiFetch<T>(
|
export async function apiFetch<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options?: { method?: string; body?: unknown; signal?: AbortSignal }
|
options?: { method?: string; body?: unknown; signal?: AbortSignal },
|
||||||
): Promise<[T | null, { status: number; message: string } | null]> {
|
): Promise<[T | null, { status: number; message: string } | null]> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(endpoint, {
|
const resp = await fetch(endpoint, {
|
||||||
@ -36,14 +36,17 @@ export async function apiFetch<T>(
|
|||||||
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
headers: options?.body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
})
|
});
|
||||||
const data = await resp.json().catch(() => null)
|
const data = await resp.json().catch(() => null);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return [null, { status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` }]
|
return [
|
||||||
|
null,
|
||||||
|
{ status: resp.status, message: data?.message || data?.error || `HTTP ${resp.status}` },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return [data as T, null]
|
return [data as T, null];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }]
|
return [null, { status: 0, message: e instanceof Error ? e.message : 'Network error' }];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,10 +55,10 @@ export async function apiFetch<T>(
|
|||||||
*/
|
*/
|
||||||
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
|
export async function apiGetSilent<T>(endpoint: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(endpoint)
|
const resp = await fetch(endpoint);
|
||||||
if (!resp.ok) return null
|
if (!resp.ok) return null;
|
||||||
return await resp.json() as T
|
return (await resp.json()) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,35 @@
|
|||||||
import { API, apiFetch } from './client'
|
import { API, apiFetch } from './client';
|
||||||
import type { AppConfig } from '../components/Settings/types'
|
import type { AppConfig } from '../components/Settings/types';
|
||||||
|
|
||||||
export interface RestartResponse {
|
export interface RestartResponse {
|
||||||
success: boolean
|
success: boolean;
|
||||||
message?: string
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppConfig(): Promise<[AppConfig | null, string | null]> {
|
export async function getAppConfig(): Promise<[AppConfig | null, string | null]> {
|
||||||
const [data, err] = await apiFetch<AppConfig>(API.config)
|
const [data, err] = await apiFetch<AppConfig>(API.config);
|
||||||
return [data, err?.message ?? null]
|
return [data, err?.message ?? null];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> {
|
export async function updateAppConfig(config: AppConfig): Promise<[true, null] | [false, string]> {
|
||||||
const [, err] = await apiFetch<{ success: boolean }>(API.config, { method: 'PUT', body: { config } })
|
const [, err] = await apiFetch<{ success: boolean }>(API.config, {
|
||||||
return err ? [false, err.message] : [true, null]
|
method: 'PUT',
|
||||||
|
body: { config },
|
||||||
|
});
|
||||||
|
return err ? [false, err.message] : [true, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> {
|
export async function restartGateway(): Promise<{ status: number; data: RestartResponse }> {
|
||||||
const resp = await fetch(API.restart, { method: 'POST' })
|
const resp = await fetch(API.restart, { method: 'POST' });
|
||||||
const data = await resp.json().catch(() => ({ success: false }))
|
const data = await resp.json().catch(() => ({ success: false }));
|
||||||
return { status: resp.status, data }
|
return { status: resp.status, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(): Promise<boolean> {
|
export async function checkHealth(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(API.health)
|
const resp = await fetch(API.health);
|
||||||
return resp.ok
|
return resp.ok;
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +1,113 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client';
|
||||||
import type { ExpertListResponse, ExpertItem, CapabilityPolicy, ModelOptionsResponse } from '../components/Settings/types'
|
import type {
|
||||||
|
ExpertListResponse,
|
||||||
|
ExpertItem,
|
||||||
|
CapabilityPolicy,
|
||||||
|
ModelOptionsResponse,
|
||||||
|
} from '../components/Settings/types';
|
||||||
|
|
||||||
export function listExperts(): Promise<ExpertListResponse | null> {
|
export function listExperts(): Promise<ExpertListResponse | null> {
|
||||||
return apiGetSilent<ExpertListResponse>(API.experts)
|
return apiGetSilent<ExpertListResponse>(API.experts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listModelOptions(): Promise<ModelOptionsResponse | null> {
|
export function listModelOptions(): Promise<ModelOptionsResponse | null> {
|
||||||
return apiGetSilent<ModelOptionsResponse>(API.modelOptions)
|
return apiGetSilent<ModelOptionsResponse>(API.modelOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleExpert(name: string, scope: string, enabled: boolean): Promise<Response> {
|
export async function toggleExpert(
|
||||||
|
name: string,
|
||||||
|
scope: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<Response> {
|
||||||
return fetch(API.expertsToggle, {
|
return fetch(API.expertsToggle, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, scope, enabled }),
|
body: JSON.stringify({ name, scope, enabled }),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createExpert(payload: { name: string; description: string; body: string; scope: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> {
|
export async function createExpert(payload: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
scope: string;
|
||||||
|
capability?: CapabilityPolicy;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
}): Promise<Response> {
|
||||||
return fetch(API.expertsCreate, {
|
return fetch(API.expertsCreate, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateExpert(payload: { name: string; scope: string; description?: string; body?: string; capability?: CapabilityPolicy; provider?: string; model?: string }): Promise<Response> {
|
export async function updateExpert(payload: {
|
||||||
|
name: string;
|
||||||
|
scope: string;
|
||||||
|
description?: string;
|
||||||
|
body?: string;
|
||||||
|
capability?: CapabilityPolicy;
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
}): Promise<Response> {
|
||||||
return fetch(API.expertsUpdate, {
|
return fetch(API.expertsUpdate, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteExpert(name: string, scope: string): Promise<Response> {
|
export async function deleteExpert(name: string, scope: string): Promise<Response> {
|
||||||
const params = new URLSearchParams({ name, scope })
|
const params = new URLSearchParams({ name, scope });
|
||||||
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' })
|
return fetch(`${API.expertsDelete}?${params}`, { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSelectedExpert(sessionId: string): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
|
export async function getSelectedExpert(
|
||||||
const params = new URLSearchParams({ session_id: sessionId })
|
sessionId: string,
|
||||||
const resp = await fetch(`${API.expertsSelected}?${params}`)
|
): Promise<{ expert_name: string | null; expert: ExpertItem | null }> {
|
||||||
if (!resp.ok) return { expert_name: null, expert: null }
|
const params = new URLSearchParams({ session_id: sessionId });
|
||||||
return resp.json()
|
const resp = await fetch(`${API.expertsSelected}?${params}`);
|
||||||
|
if (!resp.ok) return { expert_name: null, expert: null };
|
||||||
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function selectExpert(sessionId: string, expertName: string | null): Promise<{ success: boolean; error?: string }> {
|
export async function selectExpert(
|
||||||
|
sessionId: string,
|
||||||
|
expertName: string | null,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const resp = await fetch(API.expertsSelect, {
|
const resp = await fetch(API.expertsSelect, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
|
body: JSON.stringify({ session_id: sessionId, expert_name: expertName }),
|
||||||
})
|
});
|
||||||
const data = await resp.json().catch(() => ({}))
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' }
|
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换专家失败' };
|
||||||
return { success: true }
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置(或清除)session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */
|
/** 设置(或清除)session 的用户模型覆盖。provider/model 均为空时清除覆盖(继承默认) */
|
||||||
export async function selectModel(sessionId: string, provider: string | null, model: string | null): Promise<{ success: boolean; error?: string }> {
|
export async function selectModel(
|
||||||
|
sessionId: string,
|
||||||
|
provider: string | null,
|
||||||
|
model: string | null,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
const resp = await fetch(API.sessionSelectModel, {
|
const resp = await fetch(API.sessionSelectModel, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ session_id: sessionId, provider, model }),
|
body: JSON.stringify({ session_id: sessionId, provider, model }),
|
||||||
})
|
});
|
||||||
const data = await resp.json().catch(() => ({}))
|
const data = await resp.json().catch(() => ({}));
|
||||||
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' }
|
if (!resp.ok || !data.success) return { success: false, error: data.error || '切换模型失败' };
|
||||||
return { success: true }
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */
|
/** 读取 session 当前的用户模型覆盖。provider/model 均为 null 表示未设置(继承默认) */
|
||||||
export async function getSelectedModel(sessionId: string): Promise<{ provider: string | null; model: string | null }> {
|
export async function getSelectedModel(
|
||||||
const params = new URLSearchParams({ session_id: sessionId })
|
sessionId: string,
|
||||||
const resp = await fetch(`${API.sessionSelectedModel}?${params}`)
|
): Promise<{ provider: string | null; model: string | null }> {
|
||||||
if (!resp.ok) return { provider: null, model: null }
|
const params = new URLSearchParams({ session_id: sessionId });
|
||||||
return resp.json()
|
const resp = await fetch(`${API.sessionSelectedModel}?${params}`);
|
||||||
|
if (!resp.ok) return { provider: null, model: null };
|
||||||
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client';
|
||||||
import type { McpStatusResponse } from '../components/Settings/types'
|
import type { McpStatusResponse } from '../components/Settings/types';
|
||||||
|
|
||||||
export function getMcpStatus(): Promise<McpStatusResponse | null> {
|
export function getMcpStatus(): Promise<McpStatusResponse | null> {
|
||||||
return apiGetSilent<McpStatusResponse>(API.mcpStatus)
|
return apiGetSilent<McpStatusResponse>(API.mcpStatus);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,18 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client';
|
||||||
import type { SkillListResponse } from '../components/Settings/types'
|
import type { SkillListResponse } from '../components/Settings/types';
|
||||||
|
|
||||||
export function listSkills(): Promise<SkillListResponse | null> {
|
export function listSkills(): Promise<SkillListResponse | null> {
|
||||||
return apiGetSilent<SkillListResponse>(API.skills)
|
return apiGetSilent<SkillListResponse>(API.skills);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSkill(name: string, scope: string, enabled: boolean): Promise<Response> {
|
export async function toggleSkill(
|
||||||
|
name: string,
|
||||||
|
scope: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<Response> {
|
||||||
return fetch(API.skillsToggle, {
|
return fetch(API.skillsToggle, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, scope, enabled }),
|
body: JSON.stringify({ name, scope, enabled }),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client';
|
||||||
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types'
|
import type { SubagentListResponse, CapabilityPolicy } from '../components/Settings/types';
|
||||||
|
|
||||||
export function listSubagents(): Promise<SubagentListResponse | null> {
|
export function listSubagents(): Promise<SubagentListResponse | null> {
|
||||||
return apiGetSilent<SubagentListResponse>(API.subagents)
|
return apiGetSilent<SubagentListResponse>(API.subagents);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSubagent(name: string, scope: string, enabled: boolean): Promise<Response> {
|
export async function toggleSubagent(
|
||||||
|
name: string,
|
||||||
|
scope: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<Response> {
|
||||||
return fetch(API.subagentsToggle, {
|
return fetch(API.subagentsToggle, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, scope, enabled }),
|
body: JSON.stringify({ name, scope, enabled }),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSubagent(payload: {
|
export async function updateSubagent(payload: {
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
body?: string
|
body?: string;
|
||||||
capability?: CapabilityPolicy
|
capability?: CapabilityPolicy;
|
||||||
provider?: string
|
provider?: string;
|
||||||
model?: string
|
model?: string;
|
||||||
}): Promise<Response> {
|
}): Promise<Response> {
|
||||||
return fetch(API.subagentsUpdate, {
|
return fetch(API.subagentsUpdate, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { API, apiGetSilent } from './client'
|
import { API, apiGetSilent } from './client';
|
||||||
import type { ToolsListResponse } from '../components/Settings/types'
|
import type { ToolsListResponse } from '../components/Settings/types';
|
||||||
|
|
||||||
export function listTools(): Promise<ToolsListResponse | null> {
|
export function listTools(): Promise<ToolsListResponse | null> {
|
||||||
return apiGetSilent<ToolsListResponse>(API.tools)
|
return apiGetSilent<ToolsListResponse>(API.tools);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,29 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { MessageList } from './MessageList'
|
import { MessageList } from './MessageList';
|
||||||
import { MessageInput } from './MessageInput'
|
import { MessageInput } from './MessageInput';
|
||||||
import { ExpertSelector } from './ExpertSelector'
|
import { ExpertSelector } from './ExpertSelector';
|
||||||
import { ModelSelector } from './ModelSelector'
|
import { ModelSelector } from './ModelSelector';
|
||||||
import type { ChatMessage, Attachment } from '../../types/protocol'
|
import type { ChatMessage, Attachment } from '../../types/protocol';
|
||||||
|
|
||||||
interface ChatContainerProps {
|
interface ChatContainerProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
isReadOnly?: boolean
|
isReadOnly?: boolean;
|
||||||
channelName?: string
|
channelName?: string;
|
||||||
onSendMessage: (content: string, attachments: Attachment[]) => void
|
onSendMessage: (content: string, attachments: Attachment[]) => void;
|
||||||
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
|
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
|
||||||
onStop?: () => void
|
onStop?: () => void;
|
||||||
showThinking?: boolean
|
showThinking?: boolean;
|
||||||
/** 视图标识,用于保存/恢复滚动位置 */
|
/** 视图标识,用于保存/恢复滚动位置 */
|
||||||
viewKey?: string
|
viewKey?: string;
|
||||||
/** 高亮的消息 ID */
|
/** 高亮的消息 ID */
|
||||||
highlightedMessageId?: string | null
|
highlightedMessageId?: string | null;
|
||||||
/** 当前 session ID,用于专家选择 */
|
/** 当前 session ID,用于专家选择 */
|
||||||
sessionId?: string | null
|
sessionId?: string | null;
|
||||||
/** 打开设置页(用于专家管理入口) */
|
/** 打开设置页(用于专家管理入口) */
|
||||||
onOpenSettings?: () => void
|
onOpenSettings?: () => void;
|
||||||
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
|
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
|
||||||
settingsClosedTick?: number
|
settingsClosedTick?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatContainer({
|
export function ChatContainer({
|
||||||
@ -41,12 +41,21 @@ export function ChatContainer({
|
|||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
settingsClosedTick,
|
settingsClosedTick,
|
||||||
}: ChatContainerProps) {
|
}: ChatContainerProps) {
|
||||||
const [selectedExpert, setSelectedExpert] = useState<{ name: string; description: string } | null>(null)
|
const [selectedExpert, setSelectedExpert] = useState<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col relative">
|
<div className="flex h-full flex-col relative">
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-hidden relative">
|
||||||
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
|
<MessageList
|
||||||
|
messages={messages}
|
||||||
|
onNavigateToSubAgent={onNavigateToSubAgent}
|
||||||
|
showThinking={showThinking}
|
||||||
|
viewKey={viewKey}
|
||||||
|
highlightedMessageId={highlightedMessageId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
|
<div className="flex flex-wrap items-center gap-1 px-4 pt-2 max-w-5xl mx-auto w-full">
|
||||||
<ExpertSelector
|
<ExpertSelector
|
||||||
@ -55,10 +64,7 @@ export function ChatContainer({
|
|||||||
onSelectionChange={setSelectedExpert}
|
onSelectionChange={setSelectedExpert}
|
||||||
settingsClosedTick={settingsClosedTick}
|
settingsClosedTick={settingsClosedTick}
|
||||||
/>
|
/>
|
||||||
<ModelSelector
|
<ModelSelector sessionId={sessionId ?? null} settingsClosedTick={settingsClosedTick} />
|
||||||
sessionId={sessionId ?? null}
|
|
||||||
settingsClosedTick={settingsClosedTick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSend={onSendMessage}
|
onSend={onSendMessage}
|
||||||
@ -70,5 +76,5 @@ export function ChatContainer({
|
|||||||
selectedExpert={selectedExpert}
|
selectedExpert={selectedExpert}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,145 +1,150 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react'
|
import { UserCheck, ChevronDown, Loader2, Settings, Check } from 'lucide-react';
|
||||||
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts'
|
import { getSelectedExpert, selectExpert, listExperts } from '../../api/experts';
|
||||||
|
|
||||||
interface ExpertItem {
|
interface ExpertItem {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
source: string
|
source: string;
|
||||||
path?: string
|
path?: string;
|
||||||
body?: string
|
body?: string;
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedExpert {
|
interface SelectedExpert {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpertSelectorProps {
|
interface ExpertSelectorProps {
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
onManageExperts?: () => void
|
onManageExperts?: () => void;
|
||||||
onSelectionChange?: (expert: SelectedExpert | null) => void
|
onSelectionChange?: (expert: SelectedExpert | null) => void;
|
||||||
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
|
/** 设置弹窗关闭时触发的刷新信号(每次关闭时递增) */
|
||||||
settingsClosedTick?: number
|
settingsClosedTick?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange, settingsClosedTick }: ExpertSelectorProps) {
|
export function ExpertSelector({
|
||||||
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null)
|
sessionId,
|
||||||
const [expertList, setExpertList] = useState<ExpertItem[]>([])
|
onManageExperts,
|
||||||
const [open, setOpen] = useState(false)
|
onSelectionChange,
|
||||||
const [loading, setLoading] = useState(false)
|
settingsClosedTick,
|
||||||
const [listLoading, setListLoading] = useState(false)
|
}: ExpertSelectorProps) {
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [selectedExpert, setSelectedExpert] = useState<SelectedExpert | null>(null);
|
||||||
|
const [expertList, setExpertList] = useState<ExpertItem[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [listLoading, setListLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 刷新当前会话选中的专家(后端会对禁用专家返回 null)
|
// 刷新当前会话选中的专家(后端会对禁用专家返回 null)
|
||||||
const refreshSelection = useCallback(() => {
|
const refreshSelection = useCallback(() => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null);
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
getSelectedExpert(sessionId)
|
getSelectedExpert(sessionId)
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
if (data?.expert) {
|
if (data?.expert) {
|
||||||
setSelectedExpert({ name: data.expert.name, description: data.expert.description })
|
setSelectedExpert({ name: data.expert.name, description: data.expert.description });
|
||||||
onSelectionChange?.({ name: data.expert.name, description: data.expert.description })
|
onSelectionChange?.({ name: data.expert.name, description: data.expert.description });
|
||||||
} else {
|
} else {
|
||||||
// 已选专家被禁用/删除时,后端返回 null,前端同步清除
|
// 已选专家被禁用/删除时,后端返回 null,前端同步清除
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null);
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Silent fail: default to no expert
|
// Silent fail: default to no expert
|
||||||
setSelectedExpert(null)
|
setSelectedExpert(null);
|
||||||
onSelectionChange?.(null)
|
onSelectionChange?.(null);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false));
|
||||||
}, [sessionId, onSelectionChange])
|
}, [sessionId, onSelectionChange]);
|
||||||
|
|
||||||
// Load current selection whenever sessionId changes
|
// Load current selection whenever sessionId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
}, [refreshSelection])
|
}, [refreshSelection]);
|
||||||
|
|
||||||
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
|
// 设置弹窗关闭时刷新选中状态(处理已选专家被禁用/删除的情况)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsClosedTick === undefined) return
|
if (settingsClosedTick === undefined) return;
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [settingsClosedTick])
|
}, [settingsClosedTick]);
|
||||||
|
|
||||||
// Click outside to close dropdown
|
// Click outside to close dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('mousedown', handler)
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [open])
|
}, [open]);
|
||||||
|
|
||||||
const fetchExpertList = useCallback(async () => {
|
const fetchExpertList = useCallback(async () => {
|
||||||
setListLoading(true)
|
setListLoading(true);
|
||||||
const data = await listExperts()
|
const data = await listExperts();
|
||||||
if (data) {
|
if (data) {
|
||||||
// Only show enabled experts (disabled_in_scopes.length === 0)
|
// Only show enabled experts (disabled_in_scopes.length === 0)
|
||||||
const enabled = (data.experts ?? []).filter(
|
const enabled = (data.experts ?? []).filter(
|
||||||
(e: ExpertItem) => e.disabled_in_scopes.length === 0
|
(e: ExpertItem) => e.disabled_in_scopes.length === 0,
|
||||||
)
|
);
|
||||||
setExpertList(enabled)
|
setExpertList(enabled);
|
||||||
}
|
}
|
||||||
setListLoading(false)
|
setListLoading(false);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleToggleOpen = () => {
|
const handleToggleOpen = () => {
|
||||||
const next = !open
|
const next = !open;
|
||||||
setOpen(next)
|
setOpen(next);
|
||||||
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
|
// 每次打开都重新拉取列表和选中状态,确保设置页面的启用/禁用变更能及时反映
|
||||||
if (next) {
|
if (next) {
|
||||||
fetchExpertList()
|
fetchExpertList();
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSelect = async (expert: SelectedExpert | null) => {
|
const handleSelect = async (expert: SelectedExpert | null) => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return;
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const prev = selectedExpert
|
const prev = selectedExpert;
|
||||||
setSelectedExpert(expert)
|
setSelectedExpert(expert);
|
||||||
onSelectionChange?.(expert)
|
onSelectionChange?.(expert);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
try {
|
try {
|
||||||
const result = await selectExpert(sessionId, expert?.name ?? null)
|
const result = await selectExpert(sessionId, expert?.name ?? null);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Revert
|
// Revert
|
||||||
setSelectedExpert(prev)
|
setSelectedExpert(prev);
|
||||||
onSelectionChange?.(prev)
|
onSelectionChange?.(prev);
|
||||||
setError(result.error || '切换专家失败')
|
setError(result.error || '切换专家失败');
|
||||||
setTimeout(() => setError(null), 3000)
|
setTimeout(() => setError(null), 3000);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSelectedExpert(prev)
|
setSelectedExpert(prev);
|
||||||
onSelectionChange?.(prev)
|
onSelectionChange?.(prev);
|
||||||
setError('网络错误,切换专家失败')
|
setError('网络错误,切换专家失败');
|
||||||
setTimeout(() => setError(null), 3000)
|
setTimeout(() => setError(null), 3000);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleManage = () => {
|
const handleManage = () => {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
onManageExperts?.()
|
onManageExperts?.();
|
||||||
}
|
};
|
||||||
|
|
||||||
// If sessionId is null, render nothing
|
// If sessionId is null, render nothing
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||||
@ -148,7 +153,9 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
onClick={handleToggleOpen}
|
onClick={handleToggleOpen}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
||||||
title={selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'}
|
title={
|
||||||
|
selectedExpert ? `${selectedExpert.name}: ${selectedExpert.description}` : '未选中专家'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||||||
@ -175,9 +182,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden">
|
||||||
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-72 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden"
|
|
||||||
>
|
|
||||||
{listLoading && expertList.length === 0 ? (
|
{listLoading && expertList.length === 0 ? (
|
||||||
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
<div className="flex items-center gap-2 px-3 py-3 text-xs text-[var(--text-muted)]">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
<Loader2 className="h-3.5 w-3.5 animate-spin" /> 加载中...
|
||||||
@ -197,12 +202,14 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
</button>
|
</button>
|
||||||
{expertList.length > 0 ? (
|
{expertList.length > 0 ? (
|
||||||
<div className="border-t border-[var(--border-color)]">
|
<div className="border-t border-[var(--border-color)]">
|
||||||
{expertList.map(expert => {
|
{expertList.map((expert) => {
|
||||||
const isSelected = selectedExpert?.name === expert.name
|
const isSelected = selectedExpert?.name === expert.name;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={expert.name}
|
key={expert.name}
|
||||||
onClick={() => handleSelect({ name: expert.name, description: expert.description })}
|
onClick={() =>
|
||||||
|
handleSelect({ name: expert.name, description: expert.description })
|
||||||
|
}
|
||||||
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-[var(--bg-hover)] transition-colors"
|
||||||
>
|
>
|
||||||
<UserCheck
|
<UserCheck
|
||||||
@ -230,7 +237,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -249,9 +256,7 @@ export function ExpertSelector({ sessionId, onManageExperts, onSelectionChange,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
|
||||||
<span className="text-xs text-red-400 truncate">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,33 +1,45 @@
|
|||||||
import { Send, Loader2, Square, Sparkles, Eye, Paperclip, X, FileIcon, ImageIcon, MusicIcon, VideoIcon } from 'lucide-react'
|
import {
|
||||||
import { useState, useRef, useEffect } from 'react'
|
Send,
|
||||||
import type { Attachment } from '../../types/protocol'
|
Loader2,
|
||||||
|
Square,
|
||||||
|
Sparkles,
|
||||||
|
Eye,
|
||||||
|
Paperclip,
|
||||||
|
X,
|
||||||
|
FileIcon,
|
||||||
|
ImageIcon,
|
||||||
|
MusicIcon,
|
||||||
|
VideoIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import type { Attachment } from '../../types/protocol';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
interface MessageInputProps {
|
interface MessageInputProps {
|
||||||
onSend: (content: string, attachments: Attachment[]) => void
|
onSend: (content: string, attachments: Attachment[]) => void;
|
||||||
onStop?: () => void
|
onStop?: () => void;
|
||||||
disabled?: boolean
|
disabled?: boolean;
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
isReadOnly?: boolean
|
isReadOnly?: boolean;
|
||||||
channelName?: string
|
channelName?: string;
|
||||||
selectedExpert?: { name: string; description: string } | null
|
selectedExpert?: { name: string; description: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
id: string
|
id: string;
|
||||||
file: File
|
file: File;
|
||||||
attachment: Attachment
|
attachment: Attachment;
|
||||||
preview?: string // 用于图片预览
|
preview?: string; // 用于图片预览
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 MIME 类型判断 media_type
|
// 根据 MIME 类型判断 media_type
|
||||||
function getMediaType(mimeType: string): string {
|
function getMediaType(mimeType: string): string {
|
||||||
if (mimeType.startsWith('image/')) return 'image'
|
if (mimeType.startsWith('image/')) return 'image';
|
||||||
if (mimeType.startsWith('audio/')) return 'audio'
|
if (mimeType.startsWith('audio/')) return 'audio';
|
||||||
if (mimeType.startsWith('video/')) return 'video'
|
if (mimeType.startsWith('video/')) return 'video';
|
||||||
return 'file'
|
return 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageInput({
|
export function MessageInput({
|
||||||
@ -40,49 +52,50 @@ export function MessageInput({
|
|||||||
channelName,
|
channelName,
|
||||||
selectedExpert,
|
selectedExpert,
|
||||||
}: MessageInputProps) {
|
}: MessageInputProps) {
|
||||||
const effectivePlaceholder = placeholder
|
const effectivePlaceholder =
|
||||||
?? (selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令')
|
placeholder ??
|
||||||
const [content, setContent] = useState('')
|
(selectedExpert ? `以 ${selectedExpert.name} 专家身份对话...` : '输入消息...按 / 查看命令');
|
||||||
const [attachments, setAttachments] = useState<FileAttachment[]>([])
|
const [content, setContent] = useState('');
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [attachments, setAttachments] = useState<FileAttachment[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const wasLoadingRef = useRef(false)
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wasLoadingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.style.height = 'auto'
|
textarea.style.height = 'auto';
|
||||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||||
}
|
}
|
||||||
}, [content])
|
}, [content]);
|
||||||
|
|
||||||
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
|
// 当 isLoading 从 true 变为 false 时,自动聚焦输入框
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
|
if (wasLoadingRef.current && !isLoading && !isReadOnly) {
|
||||||
textareaRef.current?.focus()
|
textareaRef.current?.focus();
|
||||||
}
|
}
|
||||||
wasLoadingRef.current = isLoading
|
wasLoadingRef.current = isLoading;
|
||||||
}, [isLoading, isReadOnly])
|
}, [isLoading, isReadOnly]);
|
||||||
|
|
||||||
// 处理文件选择
|
// 处理文件选择
|
||||||
const handleFileSelect = async (files: FileList | null) => {
|
const handleFileSelect = async (files: FileList | null) => {
|
||||||
if (!files) return
|
if (!files) return;
|
||||||
setError(null)
|
setError(null);
|
||||||
|
|
||||||
const newAttachments: FileAttachment[] = []
|
const newAttachments: FileAttachment[] = [];
|
||||||
for (const file of Array.from(files)) {
|
for (const file of Array.from(files)) {
|
||||||
// 检查文件大小
|
// 检查文件大小
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
setError(`文件 "${file.name}" 超过 50MB 限制`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取文件为 base64
|
// 读取文件为 base64
|
||||||
const base64 = await readFileAsBase64(file)
|
const base64 = await readFileAsBase64(file);
|
||||||
const mimeType = file.type || 'application/octet-stream'
|
const mimeType = file.type || 'application/octet-stream';
|
||||||
const mediaType = getMediaType(mimeType)
|
const mediaType = getMediaType(mimeType);
|
||||||
|
|
||||||
const attachment: Attachment = {
|
const attachment: Attachment = {
|
||||||
path: file.name,
|
path: file.name,
|
||||||
@ -90,78 +103,78 @@ export function MessageInput({
|
|||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
content_base64: base64,
|
content_base64: base64,
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
}
|
};
|
||||||
|
|
||||||
const fileAttachment: FileAttachment = {
|
const fileAttachment: FileAttachment = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file,
|
file,
|
||||||
attachment,
|
attachment,
|
||||||
preview: mediaType === 'image' ? base64 : undefined,
|
preview: mediaType === 'image' ? base64 : undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
newAttachments.push(fileAttachment)
|
newAttachments.push(fileAttachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
setAttachments(prev => [...prev, ...newAttachments])
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||||
}
|
};
|
||||||
|
|
||||||
// 读取文件为 base64
|
// 读取文件为 base64
|
||||||
const readFileAsBase64 = (file: File): Promise<string> => {
|
const readFileAsBase64 = (file: File): Promise<string> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
const result = reader.result as string
|
const result = reader.result as string;
|
||||||
// 移除 data:xxx;base64, 前缀
|
// 移除 data:xxx;base64, 前缀
|
||||||
const base64 = result.split(',')[1]
|
const base64 = result.split(',')[1];
|
||||||
resolve(base64)
|
resolve(base64);
|
||||||
}
|
};
|
||||||
reader.onerror = reject
|
reader.onerror = reject;
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
// 点击附件按钮
|
// 点击附件按钮
|
||||||
const handleAttachClick = () => {
|
const handleAttachClick = () => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click();
|
||||||
}
|
};
|
||||||
|
|
||||||
// 删除附件
|
// 删除附件
|
||||||
const handleRemoveAttachment = (index: number) => {
|
const handleRemoveAttachment = (index: number) => {
|
||||||
setAttachments(prev => prev.filter((_, i) => i !== index))
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||||
}
|
};
|
||||||
|
|
||||||
// 粘贴事件处理
|
// 粘贴事件处理
|
||||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||||
if (disabled || isReadOnly) return
|
if (disabled || isReadOnly) return;
|
||||||
|
|
||||||
const clipboardData = e.clipboardData
|
const clipboardData = e.clipboardData;
|
||||||
const items = clipboardData.items
|
const items = clipboardData.items;
|
||||||
|
|
||||||
// 检查是否有文件(图片或其他文件)
|
// 检查是否有文件(图片或其他文件)
|
||||||
const files: File[] = []
|
const files: File[] = [];
|
||||||
for (const item of Array.from(items)) {
|
for (const item of Array.from(items)) {
|
||||||
if (item.kind === 'file') {
|
if (item.kind === 'file') {
|
||||||
const file = item.getAsFile()
|
const file = item.getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
files.push(file)
|
files.push(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有文件,处理文件并阻止默认粘贴行为
|
// 如果有文件,处理文件并阻止默认粘贴行为
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
// 直接处理文件数组
|
// 直接处理文件数组
|
||||||
setError(null)
|
setError(null);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
setError(`文件 "${file.name}" 超过 50MB 限制`)
|
setError(`文件 "${file.name}" 超过 50MB 限制`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base64 = await readFileAsBase64(file)
|
const base64 = await readFileAsBase64(file);
|
||||||
const mimeType = file.type || 'application/octet-stream'
|
const mimeType = file.type || 'application/octet-stream';
|
||||||
const mediaType = getMediaType(mimeType)
|
const mediaType = getMediaType(mimeType);
|
||||||
|
|
||||||
const attachment: Attachment = {
|
const attachment: Attachment = {
|
||||||
path: file.name,
|
path: file.name,
|
||||||
@ -169,91 +182,91 @@ export function MessageInput({
|
|||||||
mime_type: mimeType,
|
mime_type: mimeType,
|
||||||
content_base64: base64,
|
content_base64: base64,
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
}
|
};
|
||||||
|
|
||||||
const fileAttachment: FileAttachment = {
|
const fileAttachment: FileAttachment = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
file,
|
file,
|
||||||
attachment,
|
attachment,
|
||||||
preview: mediaType === 'image' ? base64 : undefined,
|
preview: mediaType === 'image' ? base64 : undefined,
|
||||||
}
|
};
|
||||||
|
|
||||||
setAttachments(prev => [...prev, fileAttachment])
|
setAttachments((prev) => [...prev, fileAttachment]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 否则让默认的文本粘贴行为继续
|
// 否则让默认的文本粘贴行为继续
|
||||||
}
|
};
|
||||||
|
|
||||||
// 拖拽事件
|
// 拖拽事件
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
if (!disabled && !isReadOnly) {
|
if (!disabled && !isReadOnly) {
|
||||||
setIsDragging(true)
|
setIsDragging(true);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
// 检查是否真的离开了拖拽区域(而不是进入子元素)
|
// 检查是否真的离开了拖拽区域(而不是进入子元素)
|
||||||
const relatedTarget = e.relatedTarget as Node | null
|
const relatedTarget = e.relatedTarget as Node | null;
|
||||||
const currentTarget = e.currentTarget
|
const currentTarget = e.currentTarget;
|
||||||
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
|
||||||
setIsDragging(false)
|
setIsDragging(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setIsDragging(false)
|
setIsDragging(false);
|
||||||
if (!disabled && !isReadOnly) {
|
if (!disabled && !isReadOnly) {
|
||||||
handleFileSelect(e.dataTransfer.files)
|
handleFileSelect(e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const hasContent = content.trim() || attachments.length > 0
|
const hasContent = content.trim() || attachments.length > 0;
|
||||||
if (hasContent && !disabled && !isReadOnly) {
|
if (hasContent && !disabled && !isReadOnly) {
|
||||||
onSend(
|
onSend(
|
||||||
content.trim(),
|
content.trim(),
|
||||||
attachments.map(a => a.attachment)
|
attachments.map((a) => a.attachment),
|
||||||
)
|
);
|
||||||
setContent('')
|
setContent('');
|
||||||
setAttachments([])
|
setAttachments([]);
|
||||||
setError(null)
|
setError(null);
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = 'auto'
|
textareaRef.current.style.height = 'auto';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
handleSend()
|
handleSend();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 获取附件图标
|
// 获取附件图标
|
||||||
const getAttachmentIcon = (mediaType: string) => {
|
const getAttachmentIcon = (mediaType: string) => {
|
||||||
switch (mediaType) {
|
switch (mediaType) {
|
||||||
case 'image':
|
case 'image':
|
||||||
return <ImageIcon className="h-4 w-4" />
|
return <ImageIcon className="h-4 w-4" />;
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return <MusicIcon className="h-4 w-4" />
|
return <MusicIcon className="h-4 w-4" />;
|
||||||
case 'video':
|
case 'video':
|
||||||
return <VideoIcon className="h-4 w-4" />
|
return <VideoIcon className="h-4 w-4" />;
|
||||||
default:
|
default:
|
||||||
return <FileIcon className="h-4 w-4" />
|
return <FileIcon className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// 只读模式:显示提示占位符
|
// 只读模式:显示提示占位符
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
@ -273,14 +286,12 @@ export function MessageInput({
|
|||||||
'当前通道仅支持查看历史消息'
|
'当前通道仅支持查看历史消息'
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-[var(--text-muted)]">
|
<p className="text-xs text-[var(--text-muted)]">请切换至 WebSocket 通道进行输入</p>
|
||||||
请切换至 WebSocket 通道进行输入
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -337,9 +348,7 @@ export function MessageInput({
|
|||||||
{/* 拖拽提示 */}
|
{/* 拖拽提示 */}
|
||||||
{isDragging && (
|
{isDragging && (
|
||||||
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10">
|
<div className="absolute inset-0 rounded-xl bg-[var(--accent-cyan)]/10 border-2 border-[var(--accent-cyan)]/50 flex items-center justify-center z-10">
|
||||||
<div className="text-[var(--accent-cyan)] text-sm font-medium">
|
<div className="text-[var(--accent-cyan)] text-sm font-medium">拖放文件到这里</div>
|
||||||
拖放文件到这里
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -405,5 +414,5 @@ export function MessageInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,141 +1,147 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { MessageBubble } from './MessageBubble'
|
import { MessageBubble } from './MessageBubble';
|
||||||
import type { ChatMessage } from '../../types/protocol'
|
import type { ChatMessage } from '../../types/protocol';
|
||||||
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react'
|
import { Sparkles, ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
interface MessageListProps {
|
interface MessageListProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void
|
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
|
||||||
showThinking?: boolean
|
showThinking?: boolean;
|
||||||
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
|
||||||
viewKey?: string
|
viewKey?: string;
|
||||||
/** 高亮的消息 ID,点击待办项后滚动并高亮显示 */
|
/** 高亮的消息 ID,点击待办项后滚动并高亮显示 */
|
||||||
highlightedMessageId?: string | null
|
highlightedMessageId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) {
|
export function MessageList({
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
messages,
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
onNavigateToSubAgent,
|
||||||
const isAtBottomRef = useRef(true)
|
showThinking = true,
|
||||||
const prevShowBottomRef = useRef(false)
|
viewKey,
|
||||||
const prevViewKeyRef = useRef(viewKey)
|
highlightedMessageId,
|
||||||
const viewKeyRef = useRef(viewKey)
|
}: MessageListProps) {
|
||||||
viewKeyRef.current = viewKey
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isAtBottomRef = useRef(true);
|
||||||
|
const prevShowBottomRef = useRef(false);
|
||||||
|
const prevViewKeyRef = useRef(viewKey);
|
||||||
|
const viewKeyRef = useRef(viewKey);
|
||||||
|
viewKeyRef.current = viewKey;
|
||||||
|
|
||||||
// Per-view scroll position memory
|
// Per-view scroll position memory
|
||||||
const scrollPositionsRef = useRef<Map<string, number>>(new Map())
|
const scrollPositionsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0)
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
|
|
||||||
// ---- scroll helpers ----
|
// ---- scroll helpers ----
|
||||||
|
|
||||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
||||||
isAtBottomRef.current = true
|
isAtBottomRef.current = true;
|
||||||
setShowScrollToBottom(false)
|
setShowScrollToBottom(false);
|
||||||
setNewMessageCount(0)
|
setNewMessageCount(0);
|
||||||
bottomRef.current?.scrollIntoView({ behavior })
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- scroll event: track whether user is at bottom ----
|
// ---- scroll event: track whether user is at bottom ----
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = containerRef.current
|
const el = containerRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
|
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
const nearBottom = distanceFromBottom < 120
|
const nearBottom = distanceFromBottom < 120;
|
||||||
|
|
||||||
// Save scroll position for current view
|
// Save scroll position for current view
|
||||||
const key = viewKeyRef.current
|
const key = viewKeyRef.current;
|
||||||
if (key) {
|
if (key) {
|
||||||
scrollPositionsRef.current.set(key, el.scrollTop)
|
scrollPositionsRef.current.set(key, el.scrollTop);
|
||||||
}
|
}
|
||||||
|
|
||||||
isAtBottomRef.current = nearBottom
|
isAtBottomRef.current = nearBottom;
|
||||||
|
|
||||||
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
|
// 回到底部:距底部 > 200px 时显示(同时显示回到顶部)
|
||||||
const shouldShowBottom = distanceFromBottom > 200
|
const shouldShowBottom = distanceFromBottom > 200;
|
||||||
if (shouldShowBottom !== prevShowBottomRef.current) {
|
if (shouldShowBottom !== prevShowBottomRef.current) {
|
||||||
prevShowBottomRef.current = shouldShowBottom
|
prevShowBottomRef.current = shouldShowBottom;
|
||||||
setShowScrollToBottom(shouldShowBottom)
|
setShowScrollToBottom(shouldShowBottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚回底部时清除新消息计数
|
// 滚回底部时清除新消息计数
|
||||||
if (nearBottom) {
|
if (nearBottom) {
|
||||||
setNewMessageCount(0)
|
setNewMessageCount(0);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- auto-scroll: handle view switches and message updates ----
|
// ---- auto-scroll: handle view switches and message updates ----
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const prevKey = prevViewKeyRef.current
|
const prevKey = prevViewKeyRef.current;
|
||||||
const viewChanged = prevKey !== viewKey
|
const viewChanged = prevKey !== viewKey;
|
||||||
prevViewKeyRef.current = viewKey
|
prevViewKeyRef.current = viewKey;
|
||||||
|
|
||||||
if (messages.length === 0) {
|
if (messages.length === 0) {
|
||||||
isAtBottomRef.current = true
|
isAtBottomRef.current = true;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (viewChanged) {
|
if (viewChanged) {
|
||||||
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
// View switched (e.g. breadcrumb navigation): restore saved scroll position
|
||||||
const key = viewKey ?? ''
|
const key = viewKey ?? '';
|
||||||
const savedPos = scrollPositionsRef.current.get(key)
|
const savedPos = scrollPositionsRef.current.get(key);
|
||||||
if (savedPos !== undefined && containerRef.current) {
|
if (savedPos !== undefined && containerRef.current) {
|
||||||
containerRef.current.scrollTop = savedPos
|
containerRef.current.scrollTop = savedPos;
|
||||||
const el = containerRef.current
|
const el = containerRef.current;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
isAtBottomRef.current = distanceFromBottom < 120
|
isAtBottomRef.current = distanceFromBottom < 120;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// First time viewing this view: scroll to bottom
|
// First time viewing this view: scroll to bottom
|
||||||
isAtBottomRef.current = true
|
isAtBottomRef.current = true;
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
bottomRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same view, messages changed: normal auto-scroll logic
|
// Same view, messages changed: normal auto-scroll logic
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
|
||||||
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
if (lastMessage.role === 'user' || isAtBottomRef.current) {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'instant' })
|
bottomRef.current?.scrollIntoView({ behavior: 'instant' });
|
||||||
} else {
|
} else {
|
||||||
setNewMessageCount((prev) => prev + 1)
|
setNewMessageCount((prev) => prev + 1);
|
||||||
}
|
}
|
||||||
}, [messages, viewKey])
|
}, [messages, viewKey]);
|
||||||
|
|
||||||
// ---- mount: always scroll to bottom if messages already loaded ----
|
// ---- mount: always scroll to bottom if messages already loaded ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
scrollToBottom('instant')
|
scrollToBottom('instant');
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- highlight and scroll to todo message ----
|
// ---- highlight and scroll to todo message ----
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!highlightedMessageId) return
|
if (!highlightedMessageId) return;
|
||||||
|
|
||||||
const container = containerRef.current
|
const container = containerRef.current;
|
||||||
if (!container) return
|
if (!container) return;
|
||||||
|
|
||||||
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
|
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`);
|
||||||
if (!targetElement) return
|
if (!targetElement) return;
|
||||||
|
|
||||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
targetElement.classList.add('todo-highlight')
|
targetElement.classList.add('todo-highlight');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
targetElement.classList.remove('todo-highlight')
|
targetElement.classList.remove('todo-highlight');
|
||||||
}, 2000)
|
}, 2000);
|
||||||
}, [highlightedMessageId])
|
}, [highlightedMessageId]);
|
||||||
|
|
||||||
// ---- empty state ----
|
// ---- empty state ----
|
||||||
|
|
||||||
@ -149,12 +155,16 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]">开始新的对话</h2>
|
<h2 className="mb-2 text-2xl font-bold text-[var(--text-primary)]">开始新的对话</h2>
|
||||||
<p className="text-[var(--text-muted)]">在下方输入消息开始与 AI 助手聊天</p>
|
<p className="text-[var(--text-muted)]">在下方输入消息开始与 AI 助手聊天</p>
|
||||||
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
|
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-[var(--text-muted)]">
|
||||||
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/new 创建话题</span>
|
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
|
||||||
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">/list 查看列表</span>
|
/new 创建话题
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 rounded-full bg-[var(--bg-hover)] border border-[var(--border-color)]">
|
||||||
|
/list 查看列表
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- main render ----
|
// ---- main render ----
|
||||||
@ -167,7 +177,12 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
className="h-full overflow-y-auto p-6 space-y-6"
|
className="h-full overflow-y-auto p-6 space-y-6"
|
||||||
>
|
>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<MessageBubble key={message.id} message={message} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} />
|
<MessageBubble
|
||||||
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
onNavigateToSubAgent={onNavigateToSubAgent}
|
||||||
|
showThinking={showThinking}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
@ -205,7 +220,9 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
animate-fade-in"
|
animate-fade-in"
|
||||||
aria-label="回到底部"
|
aria-label="回到底部"
|
||||||
>
|
>
|
||||||
<ArrowDown className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`} />
|
<ArrowDown
|
||||||
|
className={`h-4 w-4 transition-transform duration-300 ${newMessageCount > 0 ? 'animate-bounce' : ''}`}
|
||||||
|
/>
|
||||||
{newMessageCount > 0 ? (
|
{newMessageCount > 0 ? (
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
{newMessageCount} 条新消息
|
{newMessageCount} 条新消息
|
||||||
@ -223,5 +240,5 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,151 +1,157 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react'
|
import { Cpu, ChevronDown, Loader2, Check } from 'lucide-react';
|
||||||
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts'
|
import { listModelOptions, selectModel, getSelectedModel } from '../../api/experts';
|
||||||
import type { ModelOptionsResponse } from '../Settings/types'
|
import type { ModelOptionsResponse } from '../Settings/types';
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
|
/** 设置弹窗关闭信号(每次关闭递增,用于触发刷新) */
|
||||||
settingsClosedTick?: number
|
settingsClosedTick?: number;
|
||||||
/** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */
|
/** 选择变化回调(参数为生效的 provider/model,未覆盖时为 current 默认) */
|
||||||
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void
|
onSelectionChange?: (effective: { provider: string; model: string; overridden: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange }: ModelSelectorProps) {
|
export function ModelSelector({
|
||||||
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null)
|
sessionId,
|
||||||
const [userProvider, setUserProvider] = useState<string | null>(null)
|
settingsClosedTick,
|
||||||
const [userModel, setUserModel] = useState<string | null>(null)
|
onSelectionChange,
|
||||||
const [open, setOpen] = useState(false)
|
}: ModelSelectorProps) {
|
||||||
const [loading, setLoading] = useState(false)
|
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
|
||||||
const [saving, setSaving] = useState(false)
|
const [userProvider, setUserProvider] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [userModel, setUserModel] = useState<string | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
|
// 草稿:用户在 dropdown 中暂存的选择,点击应用后才提交
|
||||||
const [draftProvider, setDraftProvider] = useState<string>('')
|
const [draftProvider, setDraftProvider] = useState<string>('');
|
||||||
const [draftModel, setDraftModel] = useState<string>('')
|
const [draftModel, setDraftModel] = useState<string>('');
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 刷新当前会话的用户模型覆盖
|
// 刷新当前会话的用户模型覆盖
|
||||||
const refreshSelection = useCallback(() => {
|
const refreshSelection = useCallback(() => {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setUserProvider(null)
|
setUserProvider(null);
|
||||||
setUserModel(null)
|
setUserModel(null);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
getSelectedModel(sessionId)
|
getSelectedModel(sessionId)
|
||||||
.then(data => {
|
.then((data) => {
|
||||||
setUserProvider(data.provider)
|
setUserProvider(data.provider);
|
||||||
setUserModel(data.model)
|
setUserModel(data.model);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setUserProvider(null)
|
setUserProvider(null);
|
||||||
setUserModel(null)
|
setUserModel(null);
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false));
|
||||||
}, [sessionId])
|
}, [sessionId]);
|
||||||
|
|
||||||
// 加载模型选项(全局缓存,仅加载一次)
|
// 加载模型选项(全局缓存,仅加载一次)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (modelOptions) return
|
if (modelOptions) return;
|
||||||
listModelOptions().then(data => {
|
listModelOptions().then((data) => {
|
||||||
if (data) setModelOptions(data)
|
if (data) setModelOptions(data);
|
||||||
})
|
});
|
||||||
}, [modelOptions])
|
}, [modelOptions]);
|
||||||
|
|
||||||
// sessionId 变化时刷新用户选择
|
// sessionId 变化时刷新用户选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
}, [refreshSelection])
|
}, [refreshSelection]);
|
||||||
|
|
||||||
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
|
// 设置弹窗关闭时刷新(处理 config.json 中 provider/model 变更)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsClosedTick === undefined) return
|
if (settingsClosedTick === undefined) return;
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [settingsClosedTick])
|
}, [settingsClosedTick]);
|
||||||
|
|
||||||
// 计算生效模型并通知父组件
|
// 计算生效模型并通知父组件
|
||||||
const overridden = userProvider !== null || userModel !== null
|
const overridden = userProvider !== null || userModel !== null;
|
||||||
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? ''
|
const effectiveProvider = userProvider ?? modelOptions?.current.provider ?? '';
|
||||||
const effectiveModel = userModel ?? modelOptions?.current.model ?? ''
|
const effectiveModel = userModel ?? modelOptions?.current.model ?? '';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modelOptions) return
|
if (!modelOptions) return;
|
||||||
onSelectionChange?.({
|
onSelectionChange?.({
|
||||||
provider: effectiveProvider,
|
provider: effectiveProvider,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
overridden,
|
overridden,
|
||||||
})
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [effectiveProvider, effectiveModel, overridden, modelOptions])
|
}, [effectiveProvider, effectiveModel, overridden, modelOptions]);
|
||||||
|
|
||||||
// 点击外部关闭 dropdown
|
// 点击外部关闭 dropdown
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('mousedown', handler)
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
}, [open])
|
}, [open]);
|
||||||
|
|
||||||
const handleToggleOpen = () => {
|
const handleToggleOpen = () => {
|
||||||
const next = !open
|
const next = !open;
|
||||||
setOpen(next)
|
setOpen(next);
|
||||||
if (next) {
|
if (next) {
|
||||||
// 打开时刷新选项与当前选择,同步草稿
|
// 打开时刷新选项与当前选择,同步草稿
|
||||||
if (!modelOptions) {
|
if (!modelOptions) {
|
||||||
listModelOptions().then(data => { if (data) setModelOptions(data) })
|
listModelOptions().then((data) => {
|
||||||
|
if (data) setModelOptions(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
refreshSelection()
|
refreshSelection();
|
||||||
setDraftProvider(userProvider ?? '')
|
setDraftProvider(userProvider ?? '');
|
||||||
setDraftModel(userModel ?? '')
|
setDraftModel(userModel ?? '');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleApply = async () => {
|
const handleApply = async () => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return;
|
||||||
const provider = draftProvider.trim() || null
|
const provider = draftProvider.trim() || null;
|
||||||
const model = draftModel.trim() || null
|
const model = draftModel.trim() || null;
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
setError(null)
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const result = await selectModel(sessionId, provider, model)
|
const result = await selectModel(sessionId, provider, model);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || '切换模型失败')
|
setError(result.error || '切换模型失败');
|
||||||
setTimeout(() => setError(null), 3000)
|
setTimeout(() => setError(null), 3000);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
setUserProvider(provider)
|
setUserProvider(provider);
|
||||||
setUserModel(model)
|
setUserModel(model);
|
||||||
setOpen(false)
|
setOpen(false);
|
||||||
} catch {
|
} catch {
|
||||||
setError('网络错误,切换模型失败')
|
setError('网络错误,切换模型失败');
|
||||||
setTimeout(() => setError(null), 3000)
|
setTimeout(() => setError(null), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDraftProvider('')
|
setDraftProvider('');
|
||||||
setDraftModel('')
|
setDraftModel('');
|
||||||
}
|
};
|
||||||
|
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null;
|
||||||
|
|
||||||
// 草稿是否与已保存状态不同(用于启用"应用"按钮)
|
// 草稿是否与已保存状态不同(用于启用"应用"按钮)
|
||||||
const draftChanged =
|
const draftChanged =
|
||||||
(draftProvider || null) !== (userProvider ?? null) ||
|
(draftProvider || null) !== (userProvider ?? null) ||
|
||||||
(draftModel || null) !== (userModel ?? null)
|
(draftModel || null) !== (userModel ?? null);
|
||||||
|
|
||||||
const buttonLabel = overridden
|
const buttonLabel = overridden
|
||||||
? `${effectiveProvider}/${effectiveModel}`
|
? `${effectiveProvider}/${effectiveModel}`
|
||||||
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`
|
: `默认 ${modelOptions?.current.provider ?? ''}/${modelOptions?.current.model ?? ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
<div ref={containerRef} className="relative shrink-0 flex items-center gap-2">
|
||||||
@ -154,7 +160,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
onClick={handleToggleOpen}
|
onClick={handleToggleOpen}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
className="group inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-[var(--border-color)] bg-[var(--bg-tertiary)]/60 hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-tertiary)] transition-colors text-xs disabled:opacity-50"
|
||||||
title={overridden ? `用户覆盖: ${effectiveProvider}/${effectiveModel}` : `继承默认: ${effectiveProvider}/${effectiveModel}`}
|
title={
|
||||||
|
overridden
|
||||||
|
? `用户覆盖: ${effectiveProvider}/${effectiveModel}`
|
||||||
|
: `继承默认: ${effectiveProvider}/${effectiveModel}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-[var(--text-muted)]" />
|
||||||
@ -163,7 +173,9 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
className={`h-3.5 w-3.5 ${overridden ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}>
|
<span
|
||||||
|
className={`truncate max-w-[200px] ${overridden ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]'}`}
|
||||||
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
@ -172,9 +184,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3">
|
||||||
className="absolute z-30 bottom-full mb-1 left-1/2 -translate-x-1/2 w-80 max-w-[90vw] rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] shadow-2xl backdrop-blur-md overflow-hidden p-3 space-y-3"
|
|
||||||
>
|
|
||||||
<div className="text-xs text-[var(--text-muted)]">
|
<div className="text-xs text-[var(--text-muted)]">
|
||||||
{overridden
|
{overridden
|
||||||
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
|
? `当前: ${effectiveProvider}/${effectiveModel}(已覆盖)`
|
||||||
@ -186,12 +196,16 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
Provider
|
Provider
|
||||||
<select
|
<select
|
||||||
value={draftProvider}
|
value={draftProvider}
|
||||||
onChange={e => setDraftProvider(e.target.value)}
|
onChange={(e) => setDraftProvider(e.target.value)}
|
||||||
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">继承默认{modelOptions ? `(${modelOptions.current.provider})` : ''}</option>
|
<option value="">
|
||||||
{(modelOptions?.providers ?? []).map(p => (
|
继承默认{modelOptions ? `(${modelOptions.current.provider})` : ''}
|
||||||
<option key={p} value={p}>{p}</option>
|
</option>
|
||||||
|
{(modelOptions?.providers ?? []).map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@ -200,20 +214,22 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
Model
|
Model
|
||||||
<select
|
<select
|
||||||
value={draftModel}
|
value={draftModel}
|
||||||
onChange={e => setDraftModel(e.target.value)}
|
onChange={(e) => setDraftModel(e.target.value)}
|
||||||
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
className="mt-1 w-full rounded-md border border-[var(--border-color)] bg-[var(--bg-tertiary)] px-2 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--accent-cyan)] focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">继承默认{modelOptions ? `(${modelOptions.current.model})` : ''}</option>
|
<option value="">
|
||||||
{(modelOptions?.models ?? []).map(m => (
|
继承默认{modelOptions ? `(${modelOptions.current.model})` : ''}
|
||||||
<option key={m} value={m}>{m}</option>
|
</option>
|
||||||
|
{(modelOptions?.models ?? []).map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="text-xs text-red-400 truncate">{error}</div>}
|
||||||
<div className="text-xs text-red-400 truncate">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-1">
|
<div className="flex items-center justify-between gap-2 pt-1">
|
||||||
<button
|
<button
|
||||||
@ -232,7 +248,11 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
disabled={saving || !draftChanged}
|
disabled={saving || !draftChanged}
|
||||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="inline-flex items-center gap-1 px-3 py-1 rounded-md text-xs font-medium bg-[var(--accent-cyan)]/20 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/30 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
{saving ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
)}
|
||||||
应用
|
应用
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -240,9 +260,7 @@ export function ModelSelector({ sessionId, settingsClosedTick, onSelectionChange
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <span className="text-xs text-red-400 truncate">{error}</span>}
|
||||||
<span className="text-xs text-red-400 truncate">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +1,34 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react';
|
||||||
import { X, Terminal, Clock, Maximize2 } from 'lucide-react'
|
import { X, Terminal, Clock, Maximize2 } from 'lucide-react';
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
interface ToolDetailModalProps {
|
interface ToolDetailModalProps {
|
||||||
toolName: string
|
toolName: string;
|
||||||
status: string
|
status: string;
|
||||||
statusLabel: string
|
statusLabel: string;
|
||||||
arguments?: unknown
|
arguments?: unknown;
|
||||||
resultContent: string
|
resultContent: string;
|
||||||
callContent: string
|
callContent: string;
|
||||||
durationMs?: number
|
durationMs?: number;
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (ms < 1000) return `${ms}ms`
|
if (ms < 1000) return `${ms}ms`;
|
||||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
const minutes = Math.floor(ms / 60000)
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000)
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
return `${minutes}m ${seconds}s`
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatJSON(text: string): string {
|
function formatJSON(text: string): string {
|
||||||
if (!text) return ''
|
if (!text) return '';
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text)
|
const parsed = JSON.parse(text);
|
||||||
return JSON.stringify(parsed, null, 2)
|
return JSON.stringify(parsed, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return text
|
return text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,20 +44,23 @@ export function ToolDetailModal({
|
|||||||
}: ToolDetailModalProps) {
|
}: ToolDetailModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose();
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
const displayContent = resultContent || callContent
|
const displayContent = resultContent || callContent;
|
||||||
const formattedContent = formatJSON(displayContent)
|
const formattedContent = formatJSON(displayContent);
|
||||||
|
|
||||||
const statusColor =
|
const statusColor =
|
||||||
status === 'calling' ? 'var(--accent-amber)' :
|
status === 'calling'
|
||||||
status === 'result' ? 'var(--accent-green)' :
|
? 'var(--accent-amber)'
|
||||||
status === 'pending' ? '#f59e0b' :
|
: status === 'result'
|
||||||
'var(--text-muted)'
|
? 'var(--accent-green)'
|
||||||
|
: status === 'pending'
|
||||||
|
? '#f59e0b'
|
||||||
|
: 'var(--text-muted)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -124,9 +127,7 @@ export function ToolDetailModal({
|
|||||||
{resultContent ? '结果' : '输出'}
|
{resultContent ? '结果' : '输出'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
|
<div className="text-base leading-relaxed text-[var(--text-secondary)] font-mono whitespace-pre-wrap bg-[var(--overlay-dim)] rounded-xl p-4 overflow-x-auto border border-[var(--border-color)] markdown-content">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formattedContent}</ReactMarkdown>
|
||||||
{formattedContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -142,5 +143,5 @@ export function ToolDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { Wifi, WifiOff, Loader2 } from 'lucide-react'
|
import { Wifi, WifiOff, Loader2 } from 'lucide-react';
|
||||||
import type { ConnectionStatus } from '../types/protocol'
|
import type { ConnectionStatus } from '../types/protocol';
|
||||||
|
|
||||||
interface ConnectionStatusProps {
|
interface ConnectionStatusProps {
|
||||||
status: ConnectionStatus
|
status: ConnectionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionStatus({ status }: ConnectionStatusProps) {
|
export function ConnectionStatus({ status }: ConnectionStatusProps) {
|
||||||
@ -13,36 +13,38 @@ export function ConnectionStatus({ status }: ConnectionStatusProps) {
|
|||||||
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
icon: <Loader2 className="h-3 w-3 animate-spin" />,
|
||||||
text: '连接中',
|
text: '连接中',
|
||||||
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
|
className: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
|
||||||
}
|
};
|
||||||
case 'connected':
|
case 'connected':
|
||||||
return {
|
return {
|
||||||
icon: <Wifi className="h-3 w-3" />,
|
icon: <Wifi className="h-3 w-3" />,
|
||||||
text: '已连接',
|
text: '已连接',
|
||||||
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
|
className: 'text-emerald-400 bg-emerald-400/10 border-emerald-400/30',
|
||||||
}
|
};
|
||||||
case 'disconnected':
|
case 'disconnected':
|
||||||
return {
|
return {
|
||||||
icon: <WifiOff className="h-3 w-3" />,
|
icon: <WifiOff className="h-3 w-3" />,
|
||||||
text: '已断开',
|
text: '已断开',
|
||||||
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
|
className: 'text-zinc-400 bg-zinc-400/10 border-zinc-400/30',
|
||||||
}
|
};
|
||||||
case 'error':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
icon: <WifiOff className="h-3 w-3" />,
|
icon: <WifiOff className="h-3 w-3" />,
|
||||||
text: '连接错误',
|
text: '连接错误',
|
||||||
className: 'text-red-400 bg-red-400/10 border-red-400/30',
|
className: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const config = getStatusConfig()
|
const config = getStatusConfig();
|
||||||
|
|
||||||
if (!config) return null
|
if (!config) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}>
|
<div
|
||||||
|
className={`flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs border ${config.className}`}
|
||||||
|
>
|
||||||
{config.icon}
|
{config.icon}
|
||||||
<span>{config.text}</span>
|
<span>{config.text}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react'
|
import { Monitor, MessageSquare, ChevronDown, Eye, Pencil, Smartphone } from 'lucide-react';
|
||||||
import type { Channel } from '../../types/protocol'
|
import type { Channel } from '../../types/protocol';
|
||||||
|
|
||||||
interface ChannelSelectorProps {
|
interface ChannelSelectorProps {
|
||||||
channels: Channel[]
|
channels: Channel[];
|
||||||
selectedChannel: string
|
selectedChannel: string;
|
||||||
onSelectChannel: (channelId: string) => void
|
onSelectChannel: (channelId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
|
const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> = {
|
||||||
@ -30,73 +30,78 @@ const CHANNEL_ICONS: Record<string, { icon: React.ReactNode; color: string }> =
|
|||||||
icon: <Smartphone className="h-3.5 w-3.5" />,
|
icon: <Smartphone className="h-3.5 w-3.5" />,
|
||||||
color: 'var(--accent-green)',
|
color: 'var(--accent-green)',
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
const DEFAULT_ICON = {
|
const DEFAULT_ICON = {
|
||||||
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
icon: <MessageSquare className="h-3.5 w-3.5" />,
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ChannelSelector({
|
export function ChannelSelector({
|
||||||
channels,
|
channels,
|
||||||
selectedChannel,
|
selectedChannel,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
}: ChannelSelectorProps) {
|
}: ChannelSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
top: 0,
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
right: 0,
|
||||||
const selected = channels.find((c) => c.id === selectedChannel)
|
});
|
||||||
const iconConfig = selected ? (CHANNEL_ICONS[selected.id] || DEFAULT_ICON) : DEFAULT_ICON
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selected = channels.find((c) => c.id === selectedChannel);
|
||||||
|
const iconConfig = selected ? CHANNEL_ICONS[selected.id] || DEFAULT_ICON : DEFAULT_ICON;
|
||||||
|
|
||||||
// Calculate dropdown position when opening
|
// Calculate dropdown position when opening
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (triggerRef.current) {
|
if (triggerRef.current) {
|
||||||
const rect = triggerRef.current.getBoundingClientRect()
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
setDropdownPos({
|
setDropdownPos({
|
||||||
top: rect.bottom + 8,
|
top: rect.bottom + 8,
|
||||||
right: window.innerWidth - rect.right,
|
right: window.innerWidth - rect.right,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updatePosition()
|
updatePosition();
|
||||||
window.addEventListener('resize', updatePosition)
|
window.addEventListener('resize', updatePosition);
|
||||||
window.addEventListener('scroll', updatePosition, true)
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', updatePosition)
|
window.removeEventListener('resize', updatePosition);
|
||||||
window.removeEventListener('scroll', updatePosition, true)
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}, [isOpen, updatePosition])
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
const target = e.target as Node
|
const target = e.target as Node;
|
||||||
if (
|
if (
|
||||||
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
dropdownRef.current &&
|
||||||
triggerRef.current && !triggerRef.current.contains(target)
|
!dropdownRef.current.contains(target) &&
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(target)
|
||||||
) {
|
) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('mousedown', handleClick)
|
document.addEventListener('mousedown', handleClick);
|
||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [isOpen])
|
}, [isOpen]);
|
||||||
|
|
||||||
// Close on Escape
|
// Close on Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setIsOpen(false)
|
if (e.key === 'Escape') setIsOpen(false);
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey);
|
||||||
return () => document.removeEventListener('keydown', handleKey)
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
}, [isOpen])
|
}, [isOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -107,9 +112,10 @@ export function ChannelSelector({
|
|||||||
className={`
|
className={`
|
||||||
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
${isOpen
|
${
|
||||||
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
isOpen
|
||||||
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
|
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
||||||
|
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@ -131,7 +137,9 @@ export function ChannelSelector({
|
|||||||
{selected ? (
|
{selected ? (
|
||||||
<span
|
<span
|
||||||
className={`h-1.5 w-1.5 rounded-full ${
|
className={`h-1.5 w-1.5 rounded-full ${
|
||||||
selected.isWritable ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]' : 'bg-zinc-500'
|
selected.isWritable
|
||||||
|
? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]'
|
||||||
|
: 'bg-zinc-500'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -146,115 +154,117 @@ export function ChannelSelector({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */}
|
{/* Dropdown Panel — rendered to body via Portal to avoid stacking context clipping */}
|
||||||
{isOpen && createPortal(
|
{isOpen &&
|
||||||
<div
|
createPortal(
|
||||||
ref={dropdownRef}
|
|
||||||
className="fixed z-[9999] w-56 animate-slide-in"
|
|
||||||
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] w-56 animate-slide-in"
|
||||||
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
rounded-xl border border-[var(--border-color)]
|
rounded-xl border border-[var(--border-color)]
|
||||||
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
||||||
shadow-2xl shadow-black/40
|
shadow-2xl shadow-black/40
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* Channel List */}
|
{/* Channel List */}
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{channels.length === 0 ? (
|
{channels.length === 0 ? (
|
||||||
<div className="px-4 py-6 text-center text-xs text-[var(--text-muted)]">
|
<div className="px-4 py-6 text-center text-xs text-[var(--text-muted)]">
|
||||||
暂无可用通道
|
暂无可用通道
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
channels.map((channel, index) => {
|
channels.map((channel, index) => {
|
||||||
const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON
|
const cfg = CHANNEL_ICONS[channel.id] || DEFAULT_ICON;
|
||||||
const isActive = channel.id === selectedChannel
|
const isActive = channel.id === selectedChannel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={channel.id}
|
key={channel.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectChannel(channel.id)
|
onSelectChannel(channel.id);
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`
|
||||||
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
||||||
transition-all duration-150
|
transition-all duration-150
|
||||||
hover:bg-[var(--overlay-hover)]
|
hover:bg-[var(--overlay-hover)]
|
||||||
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${index * 40}ms`,
|
animationDelay: `${index * 40}ms`,
|
||||||
animation: 'fade-in 0.2s ease-out both',
|
animation: 'fade-in 0.2s ease-out both',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left accent bar */}
|
{/* Left accent bar */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<span
|
<span
|
||||||
className={`
|
className={`
|
||||||
flex-shrink-0 transition-colors duration-200
|
flex-shrink-0 transition-colors duration-200
|
||||||
${isActive ? 'opacity-100' : 'opacity-60 group-hover/item:opacity-100'}
|
${isActive ? 'opacity-100' : 'opacity-60 group-hover/item:opacity-100'}
|
||||||
`}
|
`}
|
||||||
style={{ color: cfg.color }}
|
style={{ color: cfg.color }}
|
||||||
>
|
|
||||||
{cfg.icon}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Name + Description */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className={`text-sm truncate transition-colors duration-200 ${
|
|
||||||
isActive
|
|
||||||
? 'text-[var(--text-primary)] font-medium'
|
|
||||||
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{channel.name}
|
{cfg.icon}
|
||||||
</div>
|
</span>
|
||||||
{channel.description && (
|
|
||||||
<div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">
|
|
||||||
{channel.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Writable badge */}
|
{/* Name + Description */}
|
||||||
<span
|
<div className="flex-1 min-w-0">
|
||||||
className={`
|
<div
|
||||||
|
className={`text-sm truncate transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'text-[var(--text-primary)] font-medium'
|
||||||
|
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{channel.name}
|
||||||
|
</div>
|
||||||
|
{channel.description && (
|
||||||
|
<div className="text-[10px] text-[var(--text-muted)] truncate mt-0.5">
|
||||||
|
{channel.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Writable badge */}
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
flex-shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]
|
flex-shrink-0 flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${channel.isWritable
|
${
|
||||||
? 'bg-emerald-400/10 text-emerald-400'
|
channel.isWritable
|
||||||
: 'bg-zinc-500/10 text-zinc-500'
|
? 'bg-emerald-400/10 text-emerald-400'
|
||||||
|
: 'bg-zinc-500/10 text-zinc-500'
|
||||||
}
|
}
|
||||||
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
|
${isActive && channel.isWritable ? 'bg-emerald-400/15' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{channel.isWritable ? (
|
{channel.isWritable ? (
|
||||||
<Pencil className="h-2.5 w-2.5" />
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="h-2.5 w-2.5" />
|
<Eye className="h-2.5 w-2.5" />
|
||||||
)}
|
)}
|
||||||
<span>{channel.isWritable ? '可写' : '只读'}</span>
|
<span>{channel.isWritable ? '可写' : '只读'}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body,
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom';
|
||||||
import { MessageSquare, ChevronDown } from 'lucide-react'
|
import { MessageSquare, ChevronDown } from 'lucide-react';
|
||||||
import type { SessionSummary } from '../../types/protocol'
|
import type { SessionSummary } from '../../types/protocol';
|
||||||
|
|
||||||
interface SessionSelectorProps {
|
interface SessionSelectorProps {
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[];
|
||||||
selectedSessionId: string | null
|
selectedSessionId: string | null;
|
||||||
onSelectSession: (sessionId: string) => void
|
onSelectSession: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionSelector({
|
export function SessionSelector({
|
||||||
@ -14,59 +14,64 @@ export function SessionSelector({
|
|||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
}: SessionSelectorProps) {
|
}: SessionSelectorProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({ top: 0, right: 0 })
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; right: number }>({
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
top: 0,
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
right: 0,
|
||||||
const selected = sessions.find((s) => s.session_id === selectedSessionId)
|
});
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selected = sessions.find((s) => s.session_id === selectedSessionId);
|
||||||
|
|
||||||
const updatePosition = useCallback(() => {
|
const updatePosition = useCallback(() => {
|
||||||
if (triggerRef.current) {
|
if (triggerRef.current) {
|
||||||
const rect = triggerRef.current.getBoundingClientRect()
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
setDropdownPos({
|
setDropdownPos({
|
||||||
top: rect.bottom + 8,
|
top: rect.bottom + 8,
|
||||||
right: window.innerWidth - rect.right,
|
right: window.innerWidth - rect.right,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
updatePosition()
|
updatePosition();
|
||||||
window.addEventListener('resize', updatePosition)
|
window.addEventListener('resize', updatePosition);
|
||||||
window.addEventListener('scroll', updatePosition, true)
|
window.addEventListener('scroll', updatePosition, true);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', updatePosition)
|
window.removeEventListener('resize', updatePosition);
|
||||||
window.removeEventListener('scroll', updatePosition, true)
|
window.removeEventListener('scroll', updatePosition, true);
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}, [isOpen, updatePosition])
|
}, [isOpen, updatePosition]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
const target = e.target as Node
|
const target = e.target as Node;
|
||||||
if (
|
if (
|
||||||
dropdownRef.current && !dropdownRef.current.contains(target) &&
|
dropdownRef.current &&
|
||||||
triggerRef.current && !triggerRef.current.contains(target)
|
!dropdownRef.current.contains(target) &&
|
||||||
|
triggerRef.current &&
|
||||||
|
!triggerRef.current.contains(target)
|
||||||
) {
|
) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener('mousedown', handleClick)
|
document.addEventListener('mousedown', handleClick);
|
||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [isOpen])
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return;
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setIsOpen(false)
|
if (e.key === 'Escape') setIsOpen(false);
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey);
|
||||||
return () => document.removeEventListener('keydown', handleKey)
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
}, [isOpen])
|
}, [isOpen]);
|
||||||
|
|
||||||
if (sessions.length === 0) return null
|
if (sessions.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -77,11 +82,12 @@ export function SessionSelector({
|
|||||||
className={`
|
className={`
|
||||||
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm w-44 justify-between
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
${sessions.length <= 1
|
${
|
||||||
? 'border-[var(--border-color)] cursor-default'
|
sessions.length <= 1
|
||||||
: isOpen
|
? 'border-[var(--border-color)] cursor-default'
|
||||||
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
: isOpen
|
||||||
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
|
? 'border-[var(--accent-cyan)]/40 bg-[var(--overlay-subtle)] shadow-[0_0_12px_var(--shadow-glow-sm)]'
|
||||||
|
: 'border-[var(--border-color)] hover:border-[var(--border-accent)] hover:bg-[var(--overlay-hover)] cursor-pointer'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@ -104,73 +110,79 @@ export function SessionSelector({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && sessions.length > 1 && createPortal(
|
{isOpen &&
|
||||||
<div
|
sessions.length > 1 &&
|
||||||
ref={dropdownRef}
|
createPortal(
|
||||||
className="fixed z-[9999] w-56 animate-slide-in"
|
|
||||||
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[9999] w-56 animate-slide-in"
|
||||||
|
style={{ top: dropdownPos.top, right: dropdownPos.right }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="
|
||||||
rounded-xl border border-[var(--border-color)]
|
rounded-xl border border-[var(--border-color)]
|
||||||
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
bg-[var(--bg-secondary)]/95 backdrop-blur-xl
|
||||||
shadow-2xl shadow-black/40
|
shadow-2xl shadow-black/40
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div className="py-1 max-h-60 overflow-y-auto">
|
<div className="py-1 max-h-60 overflow-y-auto">
|
||||||
{sessions.map((s, index) => {
|
{sessions.map((s, index) => {
|
||||||
const isActive = s.session_id === selectedSessionId
|
const isActive = s.session_id === selectedSessionId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s.session_id}
|
key={s.session_id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectSession(s.session_id)
|
onSelectSession(s.session_id);
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`
|
||||||
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
group/item relative w-full flex items-center gap-3 px-4 py-2.5 text-left
|
||||||
transition-all duration-150
|
transition-all duration-150
|
||||||
hover:bg-[var(--overlay-hover)]
|
hover:bg-[var(--overlay-hover)]
|
||||||
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
${isActive ? 'bg-[var(--overlay-subtle)]' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${index * 40}ms`,
|
animationDelay: `${index * 40}ms`,
|
||||||
animation: 'fade-in 0.2s ease-out both',
|
animation: 'fade-in 0.2s ease-out both',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r-full
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
${isActive ? 'bg-[var(--accent-cyan)] shadow-[0_0_8px_var(--accent-cyan)]' : 'bg-transparent'}
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
<MessageSquare
|
<MessageSquare
|
||||||
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
|
className={`h-3.5 w-3.5 flex-shrink-0 transition-colors duration-200 ${
|
||||||
isActive ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
|
isActive
|
||||||
}`}
|
? 'text-[var(--accent-cyan)]'
|
||||||
/>
|
: 'text-[var(--text-muted)] group-hover/item:text-[var(--text-secondary)]'
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div
|
|
||||||
className={`text-sm truncate transition-colors duration-200 ${
|
|
||||||
isActive ? 'text-[var(--text-primary)] font-medium' : 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
{s.title}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-sm truncate transition-colors duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'text-[var(--text-primary)] font-medium'
|
||||||
|
: 'text-[var(--text-secondary)] group-hover/item:text-[var(--text-primary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.title}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
|
||||||
<span className="text-[10px] text-[var(--text-muted)] flex-shrink-0">
|
{s.message_count}
|
||||||
{s.message_count}
|
</span>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
);
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
</div>,
|
document.body,
|
||||||
document.body
|
)}
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +1,167 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { Brain, User, Library, History, Cpu, Globe, Star, Package, RefreshCw, X, ChevronDown, ChevronRight, Plus, Pencil, Trash2, Check } from 'lucide-react'
|
import {
|
||||||
import type { MemorySummary, Command } from '../../types/protocol'
|
Brain,
|
||||||
|
User,
|
||||||
|
Library,
|
||||||
|
History,
|
||||||
|
Cpu,
|
||||||
|
Globe,
|
||||||
|
Star,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { MemorySummary, Command } from '../../types/protocol';
|
||||||
|
|
||||||
/* ── types ────────────────────────────────────────────── */
|
/* ── types ────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface MemoryPanelProps {
|
interface MemoryPanelProps {
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[];
|
||||||
onRefresh: () => void
|
onRefresh: () => void;
|
||||||
onClose?: () => void
|
onClose?: () => void;
|
||||||
onCreateMemory: (ns: string, key: string, content: string) => Command
|
onCreateMemory: (ns: string, key: string, content: string) => Command;
|
||||||
onUpdateMemory: (id: string, content: string) => Command
|
onUpdateMemory: (id: string, content: string) => Command;
|
||||||
onDeleteMemory: (id: string) => Command
|
onDeleteMemory: (id: string) => Command;
|
||||||
sendCommand: (cmd: Command) => void
|
sendCommand: (cmd: Command) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NamespaceConfig { label: string; icon: typeof Brain; accent: string; accentBorder: string }
|
interface NamespaceConfig {
|
||||||
|
label: string;
|
||||||
|
icon: typeof Brain;
|
||||||
|
accent: string;
|
||||||
|
accentBorder: string;
|
||||||
|
}
|
||||||
|
|
||||||
const NS: Record<string, NamespaceConfig> = {
|
const NS: Record<string, NamespaceConfig> = {
|
||||||
user: { label: '用户记忆', icon: User, accent: 'text-cyan-400', accentBorder: 'border-cyan-400/40' },
|
user: {
|
||||||
semantic: { label: '语义记忆', icon: Library, accent: 'text-amber-400', accentBorder: 'border-amber-400/40' },
|
label: '用户记忆',
|
||||||
episodic: { label: '情景记忆', icon: History, accent: 'text-purple-400', accentBorder: 'border-purple-400/40' },
|
icon: User,
|
||||||
skill: { label: '技能记忆', icon: Cpu, accent: 'text-green-400', accentBorder: 'border-green-400/40' },
|
accent: 'text-cyan-400',
|
||||||
environment: { label: '环境记忆', icon: Globe, accent: 'text-sky-400', accentBorder: 'border-sky-400/40' },
|
accentBorder: 'border-cyan-400/40',
|
||||||
reflection: { label: '反思记忆', icon: Star, accent: 'text-rose-400', accentBorder: 'border-rose-400/40' },
|
},
|
||||||
other: { label: '其他', icon: Package, accent: 'text-stone-400', accentBorder: 'border-stone-400/40' },
|
semantic: {
|
||||||
}
|
label: '语义记忆',
|
||||||
|
icon: Library,
|
||||||
|
accent: 'text-amber-400',
|
||||||
|
accentBorder: 'border-amber-400/40',
|
||||||
|
},
|
||||||
|
episodic: {
|
||||||
|
label: '情景记忆',
|
||||||
|
icon: History,
|
||||||
|
accent: 'text-purple-400',
|
||||||
|
accentBorder: 'border-purple-400/40',
|
||||||
|
},
|
||||||
|
skill: {
|
||||||
|
label: '技能记忆',
|
||||||
|
icon: Cpu,
|
||||||
|
accent: 'text-green-400',
|
||||||
|
accentBorder: 'border-green-400/40',
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
label: '环境记忆',
|
||||||
|
icon: Globe,
|
||||||
|
accent: 'text-sky-400',
|
||||||
|
accentBorder: 'border-sky-400/40',
|
||||||
|
},
|
||||||
|
reflection: {
|
||||||
|
label: '反思记忆',
|
||||||
|
icon: Star,
|
||||||
|
accent: 'text-rose-400',
|
||||||
|
accentBorder: 'border-rose-400/40',
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
label: '其他',
|
||||||
|
icon: Package,
|
||||||
|
accent: 'text-stone-400',
|
||||||
|
accentBorder: 'border-stone-400/40',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function cfg(ns: string): NamespaceConfig {
|
function cfg(ns: string): NamespaceConfig {
|
||||||
return NS[ns] ?? { label: ns, icon: Package, accent: 'text-[var(--text-secondary)]', accentBorder: 'border-[var(--border-color)]' }
|
return (
|
||||||
|
NS[ns] ?? {
|
||||||
|
label: ns,
|
||||||
|
icon: Package,
|
||||||
|
accent: 'text-[var(--text-secondary)]',
|
||||||
|
accentBorder: 'border-[var(--border-color)]',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtKey(k: string) { return k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }
|
function fmtKey(k: string) {
|
||||||
|
return k.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
const NS_OPTIONS = Object.entries(NS)
|
const NS_OPTIONS = Object.entries(NS);
|
||||||
|
|
||||||
/* ── Memory card with edit/delete ──────────────────────── */
|
/* ── Memory card with edit/delete ──────────────────────── */
|
||||||
|
|
||||||
function MemoryCard({ memory, config, onUpdate, onDelete }:
|
function MemoryCard({
|
||||||
{ memory: MemorySummary; config: NamespaceConfig; onUpdate: (id: string, content: string) => void; onDelete: (id: string) => void }) {
|
memory,
|
||||||
|
config,
|
||||||
const [editing, setEditing] = useState(false)
|
onUpdate,
|
||||||
const [editContent, setEditContent] = useState(memory.content)
|
onDelete,
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
}: {
|
||||||
|
memory: MemorySummary;
|
||||||
|
config: NamespaceConfig;
|
||||||
|
onUpdate: (id: string, content: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editContent, setEditContent] = useState(memory.content);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (editContent.trim() && editContent !== memory.content) {
|
if (editContent.trim() && editContent !== memory.content) {
|
||||||
onUpdate(memory.id, editContent.trim())
|
onUpdate(memory.id, editContent.trim());
|
||||||
}
|
}
|
||||||
setEditing(false)
|
setEditing(false);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
onDelete(memory.id)
|
onDelete(memory.id);
|
||||||
} else {
|
} else {
|
||||||
setConfirmDelete(true)
|
setConfirmDelete(true);
|
||||||
setTimeout(() => setConfirmDelete(false), 3000)
|
setTimeout(() => setConfirmDelete(false), 3000);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}>
|
<div
|
||||||
|
className={`group rounded-lg bg-[var(--overlay-hover)] border-l-2 ${config.accentBorder} border border-[var(--border-color)] overflow-hidden transition-all duration-200 hover:border-[var(--border-accent)]`}
|
||||||
|
>
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
{/* header row */}
|
{/* header row */}
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}>
|
<span
|
||||||
|
className={`text-[10px] font-mono uppercase tracking-wider ${config.accent} opacity-60`}
|
||||||
|
>
|
||||||
{fmtKey(memory.memory_key)}
|
{fmtKey(memory.memory_key)}
|
||||||
</span>
|
</span>
|
||||||
{/* action buttons — visible on hover */}
|
{/* action buttons — visible on hover */}
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button onClick={() => { setEditing(!editing); setEditContent(memory.content) }}
|
<button
|
||||||
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`} title="编辑">
|
onClick={() => {
|
||||||
|
setEditing(!editing);
|
||||||
|
setEditContent(memory.content);
|
||||||
|
}}
|
||||||
|
className={`p-1 rounded hover:bg-[var(--overlay-subtle)] ${editing ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-muted)]'} hover:text-[var(--accent-cyan)] transition-colors`}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
<Pencil className="h-3 w-3" />
|
<Pencil className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleDelete}
|
<button
|
||||||
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`} title={confirmDelete ? '再次点击确认删除' : '删除'}>
|
onClick={handleDelete}
|
||||||
|
className={`p-1 rounded hover:bg-red-500/10 ${confirmDelete ? 'text-red-400' : 'text-[var(--text-muted)]'} hover:text-red-400 transition-colors`}
|
||||||
|
title={confirmDelete ? '再次点击确认删除' : '删除'}
|
||||||
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -83,11 +170,23 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
|
|||||||
{/* content */}
|
{/* content */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex gap-1.5 mt-1">
|
<div className="flex gap-1.5 mt-1">
|
||||||
<textarea value={editContent} onChange={e => setEditContent(e.target.value)}
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]"
|
className="flex-1 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] resize-none focus:outline-none focus:border-[var(--accent-cyan)] min-h-[120px]"
|
||||||
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSave() } }} />
|
autoFocus
|
||||||
<button onClick={handleSave}
|
onKeyDown={(e) => {
|
||||||
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors" title="保存">
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="shrink-0 p-1.5 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors"
|
||||||
|
title="保存"
|
||||||
|
>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -96,95 +195,180 @@ function MemoryCard({ memory, config, onUpdate, onDelete }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Add memory form ───────────────────────────────────── */
|
/* ── Add memory form ───────────────────────────────────── */
|
||||||
|
|
||||||
function AddMemoryForm({ onAdd, onCancel }: { onAdd: (ns: string, key: string, content: string) => void; onCancel: () => void }) {
|
function AddMemoryForm({
|
||||||
const [ns, setNs] = useState('user')
|
onAdd,
|
||||||
const [key, setKey] = useState('')
|
onCancel,
|
||||||
const [content, setContent] = useState('')
|
}: {
|
||||||
|
onAdd: (ns: string, key: string, content: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const [ns, setNs] = useState('user');
|
||||||
|
const [key, setKey] = useState('');
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!key.trim() || !content.trim()) return
|
if (!key.trim() || !content.trim()) return;
|
||||||
onAdd(ns, key.trim(), content.trim())
|
onAdd(ns, key.trim(), content.trim());
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in">
|
<div className="rounded-xl border border-[var(--border-accent)] bg-[var(--bg-tertiary)]/80 p-3 space-y-2.5 animate-fade-in">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select value={ns} onChange={e => setNs(e.target.value)}
|
<select
|
||||||
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]">
|
value={ns}
|
||||||
{NS_OPTIONS.map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
onChange={(e) => setNs(e.target.value)}
|
||||||
|
className="text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)]"
|
||||||
|
>
|
||||||
|
{NS_OPTIONS.map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input value={key} onChange={e => setKey(e.target.value)} placeholder="键名 (如 work_preference)"
|
<input
|
||||||
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]" />
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
placeholder="键名 (如 work_preference)"
|
||||||
|
className="flex-1 text-xs bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2 py-1.5 text-[var(--text-primary)] placeholder:text-[var(--text-muted)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="内容..."
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="内容..."
|
||||||
className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]"
|
className="w-full text-sm bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg px-2.5 py-2 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] resize-none min-h-[120px]"
|
||||||
autoFocus onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit() } }} />
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<button onClick={onCancel} className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors">取消</button>
|
<button
|
||||||
<button onClick={handleSubmit} disabled={!key.trim() || !content.trim()}
|
onClick={onCancel}
|
||||||
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed">创建</button>
|
className="px-3 py-1 rounded-lg text-xs text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!key.trim() || !content.trim()}
|
||||||
|
className="px-3 py-1 rounded-lg text-xs bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section header ────────────────────────────────────── */
|
/* ── Section header ────────────────────────────────────── */
|
||||||
|
|
||||||
function SectionHeader({ config, count, isCollapsed, onClick }:
|
function SectionHeader({
|
||||||
{ config: NamespaceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
config,
|
||||||
const Icon = config.icon
|
count,
|
||||||
|
isCollapsed,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
config: NamespaceConfig;
|
||||||
|
count: number;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = config.icon;
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3">
|
<button
|
||||||
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
onClick={onClick}
|
||||||
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}
|
||||||
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
|
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── main component ────────────────────────────────────── */
|
/* ── main component ────────────────────────────────────── */
|
||||||
|
|
||||||
export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUpdateMemory, onDeleteMemory, sendCommand }: MemoryPanelProps) {
|
export function MemoryPanel({
|
||||||
|
memories,
|
||||||
|
onRefresh,
|
||||||
|
onClose,
|
||||||
|
onCreateMemory,
|
||||||
|
onUpdateMemory,
|
||||||
|
onDeleteMemory,
|
||||||
|
sendCommand,
|
||||||
|
}: MemoryPanelProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
try { const s = localStorage.getItem('picobot-memory-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() }
|
try {
|
||||||
catch (_) { return new Set() }
|
const s = localStorage.getItem('picobot-memory-collapsed');
|
||||||
})
|
return s ? new Set(JSON.parse(s)) : new Set();
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
} catch (_) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
|
||||||
const toggle = (ns: string) => {
|
const toggle = (ns: string) => {
|
||||||
setCollapsed(prev => {
|
setCollapsed((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(ns)) { next.delete(ns) } else { next.add(ns) }
|
if (next.has(ns)) {
|
||||||
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]))
|
next.delete(ns);
|
||||||
return next
|
} else {
|
||||||
})
|
next.add(ns);
|
||||||
|
}
|
||||||
|
localStorage.setItem('picobot-memory-collapsed', JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = new Map<string, MemorySummary[]>();
|
||||||
|
for (const m of memories) {
|
||||||
|
const l = grouped.get(m.namespace) || [];
|
||||||
|
l.push(m);
|
||||||
|
grouped.set(m.namespace, l);
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = new Map<string, MemorySummary[]>()
|
const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other'];
|
||||||
for (const m of memories) { const l = grouped.get(m.namespace) || []; l.push(m); grouped.set(m.namespace, l) }
|
|
||||||
|
|
||||||
const order = ['user', 'semantic', 'episodic', 'skill', 'environment', 'reflection', 'other']
|
|
||||||
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
||||||
const ai = order.indexOf(a); const bi = order.indexOf(b)
|
const ai = order.indexOf(a);
|
||||||
if (ai !== -1 && bi !== -1) return ai - bi
|
const bi = order.indexOf(b);
|
||||||
if (ai !== -1) return -1; if (bi !== -1) return 1
|
if (ai !== -1 && bi !== -1) return ai - bi;
|
||||||
return a.localeCompare(b)
|
if (ai !== -1) return -1;
|
||||||
})
|
if (bi !== -1) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreate = (ns: string, key: string, content: string) => {
|
const handleCreate = (ns: string, key: string, content: string) => {
|
||||||
sendCommand(onCreateMemory(ns, key, content))
|
sendCommand(onCreateMemory(ns, key, content));
|
||||||
setShowAddForm(false)
|
setShowAddForm(false);
|
||||||
}
|
};
|
||||||
const handleUpdate = (id: string, content: string) => { sendCommand(onUpdateMemory(id, content)) }
|
const handleUpdate = (id: string, content: string) => {
|
||||||
const handleDelete = (id: string) => { sendCommand(onDeleteMemory(id)) }
|
sendCommand(onUpdateMemory(id, content));
|
||||||
|
};
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
sendCommand(onDeleteMemory(id));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@ -194,20 +378,44 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
|
|||||||
<Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
<Brain className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">记忆</span>
|
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">记忆</span>
|
||||||
{memories.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{memories.length}</span>}
|
{memories.length > 0 && (
|
||||||
|
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
|
||||||
|
{memories.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-0.5">
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
<button onClick={() => setShowAddForm(!showAddForm)} className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`} title="新增记忆">
|
<button
|
||||||
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${showAddForm ? 'bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]' : 'text-[var(--text-muted)] hover:bg-[var(--overlay-hover)] hover:text-[var(--accent-cyan)]'}`}
|
||||||
|
title="新增记忆"
|
||||||
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新">
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{onClose && <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors" title="收起"><X className="h-3.5 w-3.5" /></button>}
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
|
title="收起"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add form */}
|
{/* add form */}
|
||||||
{showAddForm && <div className="px-3 pt-2"><AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} /></div>}
|
{showAddForm && (
|
||||||
|
<div className="px-3 pt-2">
|
||||||
|
<AddMemoryForm onAdd={handleCreate} onCancel={() => setShowAddForm(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* empty */}
|
{/* empty */}
|
||||||
{memories.length === 0 && !showAddForm && (
|
{memories.length === 0 && !showAddForm && (
|
||||||
@ -217,7 +425,11 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
|
|||||||
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
||||||
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
<Brain className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed">PicoBot 会在对话中<br />自动学习并记录关于你的信息</p>
|
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
|
||||||
|
PicoBot 会在对话中
|
||||||
|
<br />
|
||||||
|
自动学习并记录关于你的信息
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -225,23 +437,36 @@ export function MemoryPanel({ memories, onRefresh, onClose, onCreateMemory, onUp
|
|||||||
{/* list */}
|
{/* list */}
|
||||||
{memories.length > 0 && (
|
{memories.length > 0 && (
|
||||||
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
||||||
{sorted.map(ns => {
|
{sorted.map((ns) => {
|
||||||
const c = cfg(ns)
|
const c = cfg(ns);
|
||||||
const items = grouped.get(ns)!
|
const items = grouped.get(ns)!;
|
||||||
const closed = collapsed.has(ns)
|
const closed = collapsed.has(ns);
|
||||||
return (
|
return (
|
||||||
<div key={ns}>
|
<div key={ns}>
|
||||||
<SectionHeader config={c} count={items.length} isCollapsed={closed} onClick={() => toggle(ns)} />
|
<SectionHeader
|
||||||
|
config={c}
|
||||||
|
count={items.length}
|
||||||
|
isCollapsed={closed}
|
||||||
|
onClick={() => toggle(ns)}
|
||||||
|
/>
|
||||||
{!closed && (
|
{!closed && (
|
||||||
<div className="mt-1.5 space-y-1.5">
|
<div className="mt-1.5 space-y-1.5">
|
||||||
{items.map(m => <MemoryCard key={m.id} memory={m} config={c} onUpdate={handleUpdate} onDelete={handleDelete} />)}
|
{items.map((m) => (
|
||||||
|
<MemoryCard
|
||||||
|
key={m.id}
|
||||||
|
memory={m}
|
||||||
|
config={c}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +1,84 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { Package, User, Folder, RefreshCw, ChevronDown, ChevronRight, BookOpen } from 'lucide-react'
|
import {
|
||||||
import type { SkillSummary } from '../../types/protocol'
|
Package,
|
||||||
|
User,
|
||||||
|
Folder,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
BookOpen,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { SkillSummary } from '../../types/protocol';
|
||||||
|
|
||||||
/* ── types ────────────────────────────────────────────── */
|
/* ── types ────────────────────────────────────────────── */
|
||||||
|
|
||||||
interface SkillListProps {
|
interface SkillListProps {
|
||||||
skills: SkillSummary[]
|
skills: SkillSummary[];
|
||||||
onRefresh: () => void
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceConfig {
|
interface SourceConfig {
|
||||||
label: string
|
label: string;
|
||||||
icon: typeof Package
|
icon: typeof Package;
|
||||||
accent: string
|
accent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOURCE_CONFIG: Record<string, SourceConfig> = {
|
const SOURCE_CONFIG: Record<string, SourceConfig> = {
|
||||||
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
|
user: { label: '用户技能', icon: User, accent: 'text-cyan-400' },
|
||||||
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
|
useragent: { label: '用户 Agent', icon: User, accent: 'text-cyan-400' },
|
||||||
useropenclaw:{ label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
|
useropenclaw: { label: '用户 OpenClaw', icon: User, accent: 'text-cyan-400' },
|
||||||
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
|
project: { label: '项目技能', icon: Folder, accent: 'text-amber-400' },
|
||||||
projectagent:{ label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
|
projectagent: { label: '项目 Agent', icon: Folder, accent: 'text-amber-400' },
|
||||||
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' },
|
projectopenclaw: { label: '项目 OpenClaw', icon: Folder, accent: 'text-amber-400' },
|
||||||
}
|
};
|
||||||
|
|
||||||
function sourceConfig(source: string): SourceConfig {
|
function sourceConfig(source: string): SourceConfig {
|
||||||
return SOURCE_CONFIG[source] ?? { label: source, icon: Package, accent: 'text-[var(--text-secondary)]' }
|
return (
|
||||||
|
SOURCE_CONFIG[source] ?? {
|
||||||
|
label: source,
|
||||||
|
icon: Package,
|
||||||
|
accent: 'text-[var(--text-secondary)]',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Section header ────────────────────────────────────── */
|
/* ── Section header ────────────────────────────────────── */
|
||||||
|
|
||||||
function SectionHeader({ config, count, isCollapsed, onClick }:
|
function SectionHeader({
|
||||||
{ config: SourceConfig; count: number; isCollapsed: boolean; onClick: () => void }) {
|
config,
|
||||||
const Icon = config.icon
|
count,
|
||||||
|
isCollapsed,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
config: SourceConfig;
|
||||||
|
count: number;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon = config.icon;
|
||||||
return (
|
return (
|
||||||
<button onClick={onClick} className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3">
|
<button
|
||||||
{isCollapsed ? <ChevronRight className="h-3 w-3 text-[var(--text-muted)]" /> : <ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />}
|
onClick={onClick}
|
||||||
<div className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}>
|
className="sticky top-0 z-10 group flex items-center gap-2 w-full py-1.5 rounded-lg transition-colors hover:bg-[var(--overlay-hover)] bg-[var(--bg-secondary)]/90 backdrop-blur-sm -mx-3 px-3"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-[var(--text-muted)]" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center w-5 h-5 rounded-md bg-[var(--overlay-hover)] ${config.accent}`}
|
||||||
|
>
|
||||||
<Icon className="h-3 w-3" />
|
<Icon className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">{config.label}</span>
|
<span className="text-xs font-semibold text-[var(--text-primary)] tracking-tight">
|
||||||
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">{count}</span>
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] font-mono tabular-nums ml-auto">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Skill card ────────────────────────────────────────── */
|
/* ── Skill card ────────────────────────────────────────── */
|
||||||
@ -54,39 +90,55 @@ function SkillCard({ skill, config }: { skill: SkillSummary; config: SourceConfi
|
|||||||
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}>
|
<span className={`text-[10px] font-mono uppercase tracking-wider ${config.accent}`}>
|
||||||
{skill.name}
|
{skill.name}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">{skill.description}</p>
|
<p className="text-sm text-[var(--text-secondary)] leading-relaxed mt-1">
|
||||||
|
{skill.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── main component ────────────────────────────────────── */
|
/* ── main component ────────────────────────────────────── */
|
||||||
|
|
||||||
export function SkillList({ skills, onRefresh }: SkillListProps) {
|
export function SkillList({ skills, onRefresh }: SkillListProps) {
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
try { const s = localStorage.getItem('picobot-skill-collapsed'); return s ? new Set(JSON.parse(s)) : new Set() }
|
try {
|
||||||
catch (_) { return new Set() }
|
const s = localStorage.getItem('picobot-skill-collapsed');
|
||||||
})
|
return s ? new Set(JSON.parse(s)) : new Set();
|
||||||
|
} catch (_) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const toggle = (source: string) => {
|
const toggle = (source: string) => {
|
||||||
setCollapsed(prev => {
|
setCollapsed((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(source)) { next.delete(source) } else { next.add(source) }
|
if (next.has(source)) {
|
||||||
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next]))
|
next.delete(source);
|
||||||
return next
|
} else {
|
||||||
})
|
next.add(source);
|
||||||
|
}
|
||||||
|
localStorage.setItem('picobot-skill-collapsed', JSON.stringify([...next]));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const grouped = new Map<string, SkillSummary[]>();
|
||||||
|
for (const s of skills) {
|
||||||
|
const l = grouped.get(s.source) || [];
|
||||||
|
l.push(s);
|
||||||
|
grouped.set(s.source, l);
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = new Map<string, SkillSummary[]>()
|
const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw'];
|
||||||
for (const s of skills) { const l = grouped.get(s.source) || []; l.push(s); grouped.set(s.source, l) }
|
|
||||||
|
|
||||||
const order = ['user', 'useragent', 'useropenclaw', 'project', 'projectagent', 'projectopenclaw']
|
|
||||||
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
const sorted = Array.from(grouped.keys()).sort((a, b) => {
|
||||||
const ai = order.indexOf(a); const bi = order.indexOf(b)
|
const ai = order.indexOf(a);
|
||||||
if (ai !== -1 && bi !== -1) return ai - bi
|
const bi = order.indexOf(b);
|
||||||
if (ai !== -1) return -1; if (bi !== -1) return 1
|
if (ai !== -1 && bi !== -1) return ai - bi;
|
||||||
return a.localeCompare(b)
|
if (ai !== -1) return -1;
|
||||||
})
|
if (bi !== -1) return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@ -96,9 +148,17 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
|
|||||||
<BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
<BookOpen className="h-3.5 w-3.5 text-[var(--accent-cyan)]" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">技能</span>
|
<span className="text-sm font-bold text-[var(--text-primary)] tracking-tight">技能</span>
|
||||||
{skills.length > 0 && <span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">{skills.length}</span>}
|
{skills.length > 0 && (
|
||||||
|
<span className="text-[11px] font-mono text-[var(--text-muted)] tabular-nums ml-0.5">
|
||||||
|
{skills.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-0.5">
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
<button onClick={onRefresh} className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors" title="刷新">
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[var(--overlay-hover)] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -112,7 +172,10 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
|
|||||||
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
<div className="absolute inset-0 rounded-full bg-[var(--accent-cyan)]/10 blur-xl animate-pulse" />
|
||||||
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
<BookOpen className="relative h-12 w-12 text-[var(--accent-cyan)]/25" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[var(--text-muted)] leading-relaxed">暂无可用技能<br />在 .picobot/skills/ 目录下添加 SKILL.md 文件</p>
|
<p className="text-sm text-[var(--text-muted)] leading-relaxed">
|
||||||
|
暂无可用技能
|
||||||
|
<br />在 .picobot/skills/ 目录下添加 SKILL.md 文件
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -120,23 +183,30 @@ export function SkillList({ skills, onRefresh }: SkillListProps) {
|
|||||||
{/* list */}
|
{/* list */}
|
||||||
{skills.length > 0 && (
|
{skills.length > 0 && (
|
||||||
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
<div className="flex-1 overflow-y-auto px-3 pt-0 pb-2 space-y-3">
|
||||||
{sorted.map(source => {
|
{sorted.map((source) => {
|
||||||
const cfg = sourceConfig(source)
|
const cfg = sourceConfig(source);
|
||||||
const items = grouped.get(source)!
|
const items = grouped.get(source)!;
|
||||||
const closed = collapsed.has(source)
|
const closed = collapsed.has(source);
|
||||||
return (
|
return (
|
||||||
<div key={source}>
|
<div key={source}>
|
||||||
<SectionHeader config={cfg} count={items.length} isCollapsed={closed} onClick={() => toggle(source)} />
|
<SectionHeader
|
||||||
|
config={cfg}
|
||||||
|
count={items.length}
|
||||||
|
isCollapsed={closed}
|
||||||
|
onClick={() => toggle(source)}
|
||||||
|
/>
|
||||||
{!closed && (
|
{!closed && (
|
||||||
<div className="mt-1.5 space-y-1.5">
|
<div className="mt-1.5 space-y-1.5">
|
||||||
{items.map(s => <SkillCard key={s.name} skill={s} config={cfg} />)}
|
{items.map((s) => (
|
||||||
|
<SkillCard key={s.name} skill={s} config={cfg} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,43 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react'
|
import { ClipboardList, ChevronDown, RefreshCw } from 'lucide-react';
|
||||||
import type { TodoItemSummary, Command } from '../../types/protocol'
|
import type { TodoItemSummary, Command } from '../../types/protocol';
|
||||||
|
|
||||||
interface TodoPanelProps {
|
interface TodoPanelProps {
|
||||||
todos: TodoItemSummary[]
|
todos: TodoItemSummary[];
|
||||||
requestTodoList: () => Command
|
requestTodoList: () => Command;
|
||||||
sendCommand: (cmd: Command) => void
|
sendCommand: (cmd: Command) => void;
|
||||||
onTodoClick?: (todo: TodoItemSummary) => void
|
onTodoClick?: (todo: TodoItemSummary) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status config ────────────────────────────────────── */
|
/* ── status config ────────────────────────────────────── */
|
||||||
|
|
||||||
interface StatusCfg { label: string; color: string; dot: string }
|
interface StatusCfg {
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
dot: string;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS: Record<string, StatusCfg> = {
|
const STATUS: Record<string, StatusCfg> = {
|
||||||
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
|
in_progress: { label: '进行中', color: 'text-amber-400', dot: 'bg-amber-400' },
|
||||||
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
|
pending: { label: '待处理', color: 'text-slate-400', dot: 'bg-slate-500' },
|
||||||
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
|
completed: { label: '已完成', color: 'text-emerald-400', dot: 'bg-emerald-400' },
|
||||||
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
|
cancelled: { label: '已取消', color: 'text-slate-500', dot: 'bg-slate-600' },
|
||||||
}
|
};
|
||||||
|
|
||||||
function statusCfg(s: string): StatusCfg {
|
function statusCfg(s: string): StatusCfg {
|
||||||
return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' }
|
return STATUS[s] ?? { label: s, color: 'text-slate-400', dot: 'bg-slate-500' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled']
|
const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled'];
|
||||||
|
|
||||||
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
|
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
|
||||||
const map = new Map<string, TodoItemSummary[]>()
|
const map = new Map<string, TodoItemSummary[]>();
|
||||||
for (const t of todos) {
|
for (const t of todos) {
|
||||||
const list = map.get(t.status) ?? []
|
const list = map.get(t.status) ?? [];
|
||||||
list.push(t)
|
list.push(t);
|
||||||
map.set(t.status, list)
|
map.set(t.status, list);
|
||||||
}
|
}
|
||||||
return map
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── pulse dot ────────────────────────────────────────── */
|
/* ── pulse dot ────────────────────────────────────────── */
|
||||||
@ -44,55 +48,67 @@ function PulseDot() {
|
|||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-amber-400" />
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── TodoPanel ────────────────────────────────────────── */
|
/* ── TodoPanel ────────────────────────────────────────── */
|
||||||
|
|
||||||
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
|
export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(['completed', 'cancelled']))
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(
|
||||||
const prevTodoIdsRef = useRef<Set<string>>(new Set())
|
() => new Set(['completed', 'cancelled']),
|
||||||
|
);
|
||||||
|
const prevTodoIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// 新增待办时自动展开"进行中"分组
|
// 新增待办时自动展开"进行中"分组
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newIds = new Set(todos.map(t => t.id))
|
const newIds = new Set(todos.map((t) => t.id));
|
||||||
if (todos.length > 0) {
|
if (todos.length > 0) {
|
||||||
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
|
const hasNewItems = todos.some((t) => !prevTodoIdsRef.current.has(t.id));
|
||||||
if (hasNewItems) {
|
if (hasNewItems) {
|
||||||
setCollapsedGroups(prev => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
next.delete('in_progress')
|
next.delete('in_progress');
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prevTodoIdsRef.current = newIds
|
prevTodoIdsRef.current = newIds;
|
||||||
}, [todos])
|
}, [todos]);
|
||||||
|
|
||||||
const grouped = groupTodos(todos)
|
const grouped = groupTodos(todos);
|
||||||
const inProgressCount = grouped.get('in_progress')?.length ?? 0
|
const inProgressCount = grouped.get('in_progress')?.length ?? 0;
|
||||||
const totalCount = todos.length
|
const totalCount = todos.length;
|
||||||
|
|
||||||
const toggleGroup = useCallback((status: string) => {
|
const toggleGroup = useCallback((status: string) => {
|
||||||
setCollapsedGroups(prev => {
|
setCollapsedGroups((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(status)) next.delete(status); else next.add(status)
|
if (next.has(status)) next.delete(status);
|
||||||
return next
|
else next.add(status);
|
||||||
})
|
return next;
|
||||||
}, [])
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => sendCommand(requestTodoList()), [sendCommand, requestTodoList])
|
const handleRefresh = useCallback(
|
||||||
|
() => sendCommand(requestTodoList()),
|
||||||
|
[sendCommand, requestTodoList],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* title bar */}
|
{/* title bar */}
|
||||||
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
|
<div className="shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-color)]/50">
|
||||||
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
|
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]/80" />
|
||||||
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight">待办</span>
|
<span className="text-[13px] font-semibold text-[var(--text-primary)] tracking-tight">
|
||||||
|
待办
|
||||||
|
</span>
|
||||||
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
|
<span className="text-[11px] text-[var(--text-muted)]/80 tabular-nums">{totalCount}</span>
|
||||||
{inProgressCount > 0 && <PulseDot />}
|
{inProgressCount > 0 && <PulseDot />}
|
||||||
<div className="ml-auto flex items-center gap-0.5">
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
<button onClick={handleRefresh} className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors" title="刷新">
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="p-1.5 rounded-lg text-[var(--text-muted)]/50 hover:text-[var(--accent-cyan)] hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -115,12 +131,12 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{GROUP_ORDER.map(status => {
|
{GROUP_ORDER.map((status) => {
|
||||||
const items = grouped.get(status)
|
const items = grouped.get(status);
|
||||||
if (!items || items.length === 0) return null
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
const cfg = statusCfg(status)
|
const cfg = statusCfg(status);
|
||||||
const isCollapsed = collapsedGroups.has(status)
|
const isCollapsed = collapsedGroups.has(status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={status} className="mt-1 first:mt-0">
|
<div key={status} className="mt-1 first:mt-0">
|
||||||
@ -130,15 +146,21 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
|
|||||||
>
|
>
|
||||||
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
|
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0`} />
|
||||||
<span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
|
<span className={`text-[12px] font-semibold ${cfg.color}`}>{cfg.label}</span>
|
||||||
<span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>{items.length}</span>
|
<span className={`text-[10px] ${cfg.color} opacity-50 tabular-nums ml-0.5`}>
|
||||||
|
{items.length}
|
||||||
|
</span>
|
||||||
<span className="ml-auto text-[var(--text-muted)]/50">
|
<span className="ml-auto text-[var(--text-muted)]/50">
|
||||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 transition-transform duration-200 ${isCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
|
<div
|
||||||
|
className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}
|
||||||
|
>
|
||||||
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
|
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
|
||||||
{items.map(item => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onTodoClick?.(item)}
|
onClick={() => onTodoClick?.(item)}
|
||||||
@ -153,9 +175,9 @@ export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +1,60 @@
|
|||||||
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal, Maximize2 } from 'lucide-react'
|
import {
|
||||||
import { useState, useMemo } from 'react'
|
ChevronDown,
|
||||||
import type { ChatMessage } from '../../types/protocol'
|
ChevronRight,
|
||||||
import { ToolDetailModal } from '../Chat/ToolDetailModal'
|
Play,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
Terminal,
|
||||||
|
Maximize2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import type { ChatMessage } from '../../types/protocol';
|
||||||
|
import { ToolDetailModal } from '../Chat/ToolDetailModal';
|
||||||
|
|
||||||
interface ToolPanelProps {
|
interface ToolPanelProps {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolCallItem {
|
interface ToolCallItem {
|
||||||
toolCallId: string
|
toolCallId: string;
|
||||||
toolName: string
|
toolName: string;
|
||||||
status: 'calling' | 'result' | 'pending'
|
status: 'calling' | 'result' | 'pending';
|
||||||
arguments?: unknown
|
arguments?: unknown;
|
||||||
resultContent: string
|
resultContent: string;
|
||||||
callContent: string
|
callContent: string;
|
||||||
durationMs?: number
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (ms < 1000) {
|
if (ms < 1000) {
|
||||||
return `${ms}ms`
|
return `${ms}ms`;
|
||||||
}
|
}
|
||||||
if (ms < 60000) {
|
if (ms < 60000) {
|
||||||
return `${(ms / 1000).toFixed(1)}s`
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
}
|
}
|
||||||
const minutes = Math.floor(ms / 60000)
|
const minutes = Math.floor(ms / 60000);
|
||||||
const seconds = Math.floor((ms % 60000) / 1000)
|
const seconds = Math.floor((ms % 60000) / 1000);
|
||||||
return `${minutes}m ${seconds}s`
|
return `${minutes}m ${seconds}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatResultText(content: string): string {
|
function formatResultText(content: string): string {
|
||||||
if (!content) return ''
|
if (!content) return '';
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content);
|
||||||
return JSON.stringify(parsed, null, 2)
|
return JSON.stringify(parsed, null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return content
|
return content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
||||||
const map = new Map<string, ToolCallItem>()
|
const map = new Map<string, ToolCallItem>();
|
||||||
|
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue
|
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue;
|
||||||
|
|
||||||
const key = m.toolCallId || m.id
|
const key = m.toolCallId || m.id;
|
||||||
let entry = map.get(key)
|
let entry = map.get(key);
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
entry = {
|
entry = {
|
||||||
@ -56,43 +64,43 @@ function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
|
|||||||
arguments: undefined,
|
arguments: undefined,
|
||||||
resultContent: '',
|
resultContent: '',
|
||||||
callContent: '',
|
callContent: '',
|
||||||
}
|
};
|
||||||
map.set(key, entry)
|
map.set(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m.type === 'tool_call') {
|
if (m.type === 'tool_call') {
|
||||||
entry.arguments = m.arguments
|
entry.arguments = m.arguments;
|
||||||
entry.callContent = m.content
|
entry.callContent = m.content;
|
||||||
} else if (m.type === 'tool_result') {
|
} else if (m.type === 'tool_result') {
|
||||||
entry.status = 'result'
|
entry.status = 'result';
|
||||||
entry.resultContent = m.content
|
entry.resultContent = m.content;
|
||||||
entry.durationMs = m.durationMs
|
entry.durationMs = m.durationMs;
|
||||||
} else if (m.type === 'tool_pending') {
|
} else if (m.type === 'tool_pending') {
|
||||||
entry.status = 'pending'
|
entry.status = 'pending';
|
||||||
entry.resultContent = m.content
|
entry.resultContent = m.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(map.values())
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolPanel({ messages }: ToolPanelProps) {
|
export function ToolPanel({ messages }: ToolPanelProps) {
|
||||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
|
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||||
const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null)
|
const [detailModalTool, setDetailModalTool] = useState<ToolCallItem | null>(null);
|
||||||
|
|
||||||
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages])
|
const toolCalls = useMemo(() => mergeToolMessages(messages), [messages]);
|
||||||
|
|
||||||
const toggleExpand = (id: string) => {
|
const toggleExpand = (id: string) => {
|
||||||
setExpandedTools((prev) => {
|
setExpandedTools((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev);
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
next.delete(id)
|
next.delete(id);
|
||||||
} else {
|
} else {
|
||||||
next.add(id)
|
next.add(id);
|
||||||
}
|
}
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const getStatusConfig = (status: ToolCallItem['status']) => {
|
const getStatusConfig = (status: ToolCallItem['status']) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@ -104,7 +112,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
borderClass: 'border-amber-500/30',
|
borderClass: 'border-amber-500/30',
|
||||||
label: '执行中',
|
label: '执行中',
|
||||||
labelClass: 'text-amber-400',
|
labelClass: 'text-amber-400',
|
||||||
}
|
};
|
||||||
case 'result':
|
case 'result':
|
||||||
return {
|
return {
|
||||||
icon: Check,
|
icon: Check,
|
||||||
@ -113,7 +121,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
borderClass: 'border-emerald-500/30',
|
borderClass: 'border-emerald-500/30',
|
||||||
label: '已完成',
|
label: '已完成',
|
||||||
labelClass: 'text-emerald-400',
|
labelClass: 'text-emerald-400',
|
||||||
}
|
};
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return {
|
return {
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
@ -122,9 +130,9 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
borderClass: 'border-orange-500/30',
|
borderClass: 'border-orange-500/30',
|
||||||
label: '待确认',
|
label: '待确认',
|
||||||
labelClass: 'text-orange-400',
|
labelClass: 'text-orange-400',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
if (toolCalls.length === 0) {
|
if (toolCalls.length === 0) {
|
||||||
return (
|
return (
|
||||||
@ -141,123 +149,144 @@ export function ToolPanel({ messages }: ToolPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<style>{animStyles}</style>
|
<style>{animStyles}</style>
|
||||||
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
<div className="border-b border-[var(--border-color)] p-4 font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||||
<Terminal className="h-4 w-4 text-[var(--accent-cyan)]" />
|
<Terminal className="h-4 w-4 text-[var(--accent-cyan)]" />
|
||||||
工具调用
|
工具调用
|
||||||
<span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]">
|
<span className="ml-auto text-xs px-2 py-0.5 rounded-full bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)]">
|
||||||
{toolCalls.length}
|
{toolCalls.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{toolCalls.map((tool) => {
|
{toolCalls.map((tool) => {
|
||||||
const config = getStatusConfig(tool.status)
|
const config = getStatusConfig(tool.status);
|
||||||
const StatusIcon = config.icon
|
const StatusIcon = config.icon;
|
||||||
const isExpanded = expandedTools.has(tool.toolCallId)
|
const isExpanded = expandedTools.has(tool.toolCallId);
|
||||||
const hasResult = tool.resultContent.length > 0
|
const hasResult = tool.resultContent.length > 0;
|
||||||
const displayContent = tool.resultContent || tool.callContent
|
const displayContent = tool.resultContent || tool.callContent;
|
||||||
const formattedContent = formatResultText(displayContent)
|
const formattedContent = formatResultText(displayContent);
|
||||||
const previewLines = displayContent.split('\n').slice(0, 2).join('\n')
|
const previewLines = displayContent.split('\n').slice(0, 2).join('\n');
|
||||||
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200
|
const hasMore = displayContent.split('\n').length > 2 || displayContent.length > 200;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tool.toolCallId}
|
key={tool.toolCallId}
|
||||||
className={`rounded-xl border bg-[var(--bg-tertiary)]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
|
className={`rounded-xl border bg-[var(--bg-tertiary)]/50 text-sm overflow-hidden tool-card transition-colors duration-500 ${config.borderClass}`}
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleExpand(tool.toolCallId)}
|
|
||||||
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-[var(--overlay-hover)] transition-colors"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<button
|
||||||
<span className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}>
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
<StatusIcon className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`} />
|
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-[var(--overlay-hover)] transition-colors"
|
||||||
</span>
|
>
|
||||||
<span className="font-medium text-[var(--text-secondary)] truncate">{tool.toolName}</span>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}>
|
<span
|
||||||
{config.label}
|
className={`tool-status-icon ${tool.status === 'calling' && !hasResult ? 'animate-pulse' : ''}`}
|
||||||
</span>
|
>
|
||||||
{tool.status === 'result' && tool.durationMs != null && (
|
<StatusIcon
|
||||||
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
|
className={`h-3.5 w-3.5 transition-colors duration-500 ${config.iconColor}`}
|
||||||
⏱️ {formatDuration(tool.durationMs)}
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="font-medium text-[var(--text-secondary)] truncate">
|
||||||
</div>
|
{tool.toolName}
|
||||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
</span>
|
||||||
<button
|
<span
|
||||||
onClick={(e) => { e.stopPropagation(); setDetailModalTool(tool) }}
|
className={`text-xs flex-shrink-0 transition-colors duration-500 ${config.labelClass}`}
|
||||||
className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors"
|
>
|
||||||
title="放大查看"
|
{config.label}
|
||||||
>
|
</span>
|
||||||
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
{tool.status === 'result' && tool.durationMs != null && (
|
||||||
</button>
|
<span className="text-xs text-[var(--text-muted)] flex-shrink-0 tabular-nums ml-1">
|
||||||
{isExpanded ? (
|
⏱️ {formatDuration(tool.durationMs)}
|
||||||
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
</span>
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 结果预览区 — 始终可见 */}
|
|
||||||
{hasResult && (
|
|
||||||
<div className="px-3 pb-2">
|
|
||||||
<div
|
|
||||||
className={`rounded-lg bg-[var(--overlay-dim-strong)] px-2.5 py-2 text-xs text-[var(--text-secondary)] font-mono cursor-pointer hover:bg-[var(--overlay-dim-heavy)] transition-colors ${
|
|
||||||
isExpanded ? '' : 'line-clamp-2'
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleExpand(tool.toolCallId)}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<pre className="whitespace-pre-wrap break-all m-0">{formattedContent}</pre>
|
|
||||||
) : (
|
|
||||||
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!isExpanded && hasMore && (
|
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||||
<div className="text-xs text-[var(--text-muted)] mt-1 px-1">
|
<button
|
||||||
点击展开全部 ({displayContent.split('\n').length} 行)
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
)}
|
setDetailModalTool(tool);
|
||||||
</div>
|
}}
|
||||||
)}
|
className="p-0.5 rounded hover:bg-[var(--overlay-subtle)] transition-colors"
|
||||||
|
title="放大查看"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
</button>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-[var(--text-muted)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 展开区域:参数 */}
|
{/* 结果预览区 — 始终可见 */}
|
||||||
{isExpanded && tool.arguments ? (
|
{hasResult && (
|
||||||
<div className="border-t border-[var(--border-color)] px-3 py-2 bg-[var(--overlay-dim)]">
|
<div className="px-3 pb-2">
|
||||||
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">参数:</div>
|
<div
|
||||||
<pre className="rounded-lg bg-[var(--overlay-dim-heavy)] p-2 text-xs overflow-x-auto text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all">
|
className={`rounded-lg bg-[var(--overlay-dim-strong)] px-2.5 py-2 text-xs text-[var(--text-secondary)] font-mono cursor-pointer hover:bg-[var(--overlay-dim-heavy)] transition-colors ${
|
||||||
{JSON.stringify(tool.arguments, null, 2)}
|
isExpanded ? '' : 'line-clamp-2'
|
||||||
</pre>
|
}`}
|
||||||
</div>
|
onClick={() => toggleExpand(tool.toolCallId)}
|
||||||
) : null}
|
>
|
||||||
</div>
|
{isExpanded ? (
|
||||||
)
|
<pre className="whitespace-pre-wrap break-all m-0">
|
||||||
})}
|
{formattedContent}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-pre-wrap break-all">{previewLines}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isExpanded && hasMore && (
|
||||||
|
<div className="text-xs text-[var(--text-muted)] mt-1 px-1">
|
||||||
|
点击展开全部 ({displayContent.split('\n').length} 行)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 展开区域:参数 */}
|
||||||
|
{isExpanded && tool.arguments ? (
|
||||||
|
<div className="border-t border-[var(--border-color)] px-3 py-2 bg-[var(--overlay-dim)]">
|
||||||
|
<div className="text-xs font-medium text-[var(--text-muted)] mb-1">参数:</div>
|
||||||
|
<pre className="rounded-lg bg-[var(--overlay-dim-heavy)] p-2 text-xs overflow-x-auto text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-all">
|
||||||
|
{JSON.stringify(tool.arguments, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{detailModalTool && (
|
||||||
{detailModalTool && (
|
<ToolDetailModal
|
||||||
<ToolDetailModal
|
toolName={detailModalTool.toolName}
|
||||||
toolName={detailModalTool.toolName}
|
status={detailModalTool.status}
|
||||||
status={detailModalTool.status}
|
statusLabel={
|
||||||
statusLabel={detailModalTool.status === 'calling' ? '执行中' : detailModalTool.status === 'result' ? '已完成' : detailModalTool.status === 'pending' ? '待确认' : detailModalTool.status}
|
detailModalTool.status === 'calling'
|
||||||
arguments={detailModalTool.arguments}
|
? '执行中'
|
||||||
resultContent={detailModalTool.resultContent}
|
: detailModalTool.status === 'result'
|
||||||
callContent={detailModalTool.callContent}
|
? '已完成'
|
||||||
durationMs={detailModalTool.durationMs}
|
: detailModalTool.status === 'pending'
|
||||||
onClose={() => setDetailModalTool(null)}
|
? '待确认'
|
||||||
/>
|
: detailModalTool.status
|
||||||
)}
|
}
|
||||||
|
arguments={detailModalTool.arguments}
|
||||||
|
resultContent={detailModalTool.resultContent}
|
||||||
|
callContent={detailModalTool.callContent}
|
||||||
|
durationMs={detailModalTool.durationMs}
|
||||||
|
onClose={() => setDetailModalTool(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const animStyles = `
|
const animStyles = `
|
||||||
@ -284,4 +313,4 @@ const animStyles = `
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,77 +1,77 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react';
|
||||||
import { X, Wifi, RotateCcw } from 'lucide-react'
|
import { X, Wifi, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
export interface GatewaySettings {
|
export interface GatewaySettings {
|
||||||
host: string
|
host: string;
|
||||||
port: number
|
port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_HOST = '127.0.0.1'
|
const DEFAULT_HOST = '127.0.0.1';
|
||||||
const DEFAULT_PORT = 19876
|
const DEFAULT_PORT = 19876;
|
||||||
|
|
||||||
export function getGatewaySettings(): GatewaySettings {
|
export function getGatewaySettings(): GatewaySettings {
|
||||||
try {
|
try {
|
||||||
const host = localStorage.getItem('picobot-gateway-host') || DEFAULT_HOST
|
const host = localStorage.getItem('picobot-gateway-host') || DEFAULT_HOST;
|
||||||
const portStr = localStorage.getItem('picobot-gateway-port')
|
const portStr = localStorage.getItem('picobot-gateway-port');
|
||||||
const port = portStr ? parseInt(portStr, 10) : DEFAULT_PORT
|
const port = portStr ? parseInt(portStr, 10) : DEFAULT_PORT;
|
||||||
return { host, port: isNaN(port) ? DEFAULT_PORT : port }
|
return { host, port: isNaN(port) ? DEFAULT_PORT : port };
|
||||||
} catch {
|
} catch {
|
||||||
return { host: DEFAULT_HOST, port: DEFAULT_PORT }
|
return { host: DEFAULT_HOST, port: DEFAULT_PORT };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWsUrl(settings: GatewaySettings): string {
|
export function buildWsUrl(settings: GatewaySettings): string {
|
||||||
return `ws://${settings.host}:${settings.port}/ws`
|
return `ws://${settings.host}:${settings.port}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSave: (settings: GatewaySettings) => void
|
onSave: (settings: GatewaySettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
||||||
const [host, setHost] = useState(DEFAULT_HOST)
|
const [host, setHost] = useState(DEFAULT_HOST);
|
||||||
const [port, setPort] = useState(String(DEFAULT_PORT))
|
const [port, setPort] = useState(String(DEFAULT_PORT));
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings = getGatewaySettings()
|
const settings = getGatewaySettings();
|
||||||
setHost(settings.host)
|
setHost(settings.host);
|
||||||
setPort(String(settings.port))
|
setPort(String(settings.port));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') onClose();
|
||||||
}
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose])
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const trimmedHost = host.trim()
|
const trimmedHost = host.trim();
|
||||||
const portNum = parseInt(port, 10)
|
const portNum = parseInt(port, 10);
|
||||||
|
|
||||||
if (!trimmedHost) {
|
if (!trimmedHost) {
|
||||||
setError('主机地址不能为空')
|
setError('主机地址不能为空');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||||
setError('端口号必须在 1-65535 之间')
|
setError('端口号必须在 1-65535 之间');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('')
|
setError('');
|
||||||
localStorage.setItem('picobot-gateway-host', trimmedHost)
|
localStorage.setItem('picobot-gateway-host', trimmedHost);
|
||||||
localStorage.setItem('picobot-gateway-port', String(portNum))
|
localStorage.setItem('picobot-gateway-port', String(portNum));
|
||||||
onSave({ host: trimmedHost, port: portNum })
|
onSave({ host: trimmedHost, port: portNum });
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setHost(DEFAULT_HOST)
|
setHost(DEFAULT_HOST);
|
||||||
setPort(String(DEFAULT_PORT))
|
setPort(String(DEFAULT_PORT));
|
||||||
setError('')
|
setError('');
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -107,7 +107,10 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={host}
|
value={host}
|
||||||
onChange={(e) => { setHost(e.target.value); setError('') }}
|
onChange={(e) => {
|
||||||
|
setHost(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
placeholder="127.0.0.1"
|
placeholder="127.0.0.1"
|
||||||
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
|
className="w-full px-3 py-2.5 rounded-xl bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-2 focus:ring-[var(--focus-ring)] transition-colors"
|
||||||
/>
|
/>
|
||||||
@ -121,7 +124,10 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={port}
|
value={port}
|
||||||
onChange={(e) => { setPort(e.target.value); setError('') }}
|
onChange={(e) => {
|
||||||
|
setPort(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
placeholder="19876"
|
placeholder="19876"
|
||||||
min={1}
|
min={1}
|
||||||
max={65535}
|
max={65535}
|
||||||
@ -167,5 +173,5 @@ export function SettingsModal({ onClose, onSave }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,21 @@
|
|||||||
// Config-related constants extracted from ConfigPage.tsx
|
// Config-related constants extracted from ConfigPage.tsx
|
||||||
import {
|
import {
|
||||||
Settings, Cpu, Bot, Clock, Calendar, Wrench, Brain, Image,
|
Settings,
|
||||||
Plug, Radio, Wifi, Server, Users, UserCheck,
|
Cpu,
|
||||||
} from 'lucide-react'
|
Bot,
|
||||||
import type { TabId } from './types'
|
Clock,
|
||||||
|
Calendar,
|
||||||
|
Wrench,
|
||||||
|
Brain,
|
||||||
|
Image,
|
||||||
|
Plug,
|
||||||
|
Radio,
|
||||||
|
Wifi,
|
||||||
|
Server,
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { TabId } from './types';
|
||||||
|
|
||||||
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
||||||
{ id: 'providers', label: '服务商', icon: Cpu },
|
{ id: 'providers', label: '服务商', icon: Cpu },
|
||||||
@ -21,10 +33,11 @@ export const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [
|
|||||||
{ id: 'time', label: '时间', icon: Clock },
|
{ id: 'time', label: '时间', icon: Clock },
|
||||||
{ id: 'connection', label: '连接', icon: Wifi },
|
{ id: 'connection', label: '连接', icon: Wifi },
|
||||||
{ id: 'gateway', label: '网关', icon: Server },
|
{ id: 'gateway', label: '网关', icon: Server },
|
||||||
]
|
];
|
||||||
|
|
||||||
export const inputCls = "w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors"
|
export const inputCls =
|
||||||
export const selectCls = inputCls
|
'w-full px-3 py-2 rounded-lg bg-[var(--bg-tertiary)] border border-[var(--border-color)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-muted)] focus:outline-none focus:border-[var(--accent-cyan)] focus:ring-1 focus:ring-[var(--focus-ring)] transition-colors';
|
||||||
|
export const selectCls = inputCls;
|
||||||
|
|
||||||
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
|
export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
|
||||||
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' },
|
{ value: 'Asia/Shanghai', label: 'Asia/Shanghai (中国标准时间, UTC+8)' },
|
||||||
@ -47,4 +60,4 @@ export const TIMEZONE_OPTIONS: { value: string; label: string }[] = [
|
|||||||
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' },
|
{ value: 'Pacific/Auckland', label: 'Pacific/Auckland (新西兰时间, UTC+12)' },
|
||||||
{ value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
|
{ value: 'Australia/Sydney', label: 'Australia/Sydney (澳东时间, UTC+10)' },
|
||||||
{ value: 'UTC', label: 'UTC (协调世界时)' },
|
{ value: 'UTC', label: 'UTC (协调世界时)' },
|
||||||
]
|
];
|
||||||
|
|||||||
@ -1,200 +1,277 @@
|
|||||||
// Config-related type definitions extracted from ConfigPage.tsx
|
// Config-related type definitions extracted from ConfigPage.tsx
|
||||||
|
|
||||||
export interface ProviderConfig { type: string; base_url: string; api_key: string; extra_headers: Record<string, string>; llm_timeout_secs: number; memory_maintenance_timeout_secs: number }
|
export interface ProviderConfig {
|
||||||
export interface ModelConfig { model_id: string; temperature?: number; max_tokens?: number; context_window_tokens?: number }
|
type: string;
|
||||||
export interface AgentConfig { provider: string; model: string; max_tool_iterations: number; tool_result_max_chars: number; context_tool_result_trim_chars: number }
|
base_url: string;
|
||||||
export interface GatewayConfig { host: string; port: number; show_tool_results: boolean; agent_prompt_reinject_every: number; max_concurrent_requests: number; session_ttl_hours?: number }
|
api_key: string;
|
||||||
export interface TimeConfig { timezone: string }
|
extra_headers: Record<string, string>;
|
||||||
export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] }
|
llm_timeout_secs: number;
|
||||||
export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number }
|
memory_maintenance_timeout_secs: number;
|
||||||
export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[]; max_nesting_depth: number }
|
}
|
||||||
export interface ToolsConfig { disabled: string[]; task: TaskConfig }
|
export interface ModelConfig {
|
||||||
export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number }
|
model_id: string;
|
||||||
export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number }
|
temperature?: number;
|
||||||
export interface SubagentsConfig { enabled: boolean; sources: string[] }
|
max_tokens?: number;
|
||||||
export interface ClientConfig { gateway_url: string }
|
context_window_tokens?: number;
|
||||||
|
}
|
||||||
|
export interface AgentConfig {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
max_tool_iterations: number;
|
||||||
|
tool_result_max_chars: number;
|
||||||
|
context_tool_result_trim_chars: number;
|
||||||
|
}
|
||||||
|
export interface GatewayConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
show_tool_results: boolean;
|
||||||
|
agent_prompt_reinject_every: number;
|
||||||
|
max_concurrent_requests: number;
|
||||||
|
session_ttl_hours?: number;
|
||||||
|
}
|
||||||
|
export interface TimeConfig {
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
export interface SchedulerConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
tick_resolution_ms: number;
|
||||||
|
worker_queue_capacity: number;
|
||||||
|
misfire_policy: 'skip' | 'catch_up';
|
||||||
|
jobs?: SchedulerJobConfig[];
|
||||||
|
}
|
||||||
|
export interface SkillsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sources: string[];
|
||||||
|
max_index_chars: number;
|
||||||
|
max_listed_skills: number;
|
||||||
|
}
|
||||||
|
export interface TaskConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
max_execution_secs: number;
|
||||||
|
ttl_hours: number;
|
||||||
|
allowed_tools: string[];
|
||||||
|
max_nesting_depth: number;
|
||||||
|
}
|
||||||
|
export interface ToolsConfig {
|
||||||
|
disabled: string[];
|
||||||
|
task: TaskConfig;
|
||||||
|
}
|
||||||
|
export interface MemoryMaintenanceConfig {
|
||||||
|
max_merge_ratio: number;
|
||||||
|
min_memories_to_keep: number;
|
||||||
|
max_merge_per_group: number;
|
||||||
|
}
|
||||||
|
export interface ImageContextConfig {
|
||||||
|
max_images_in_context: number;
|
||||||
|
max_image_age_rounds: number;
|
||||||
|
}
|
||||||
|
export interface SubagentsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sources: string[];
|
||||||
|
}
|
||||||
|
export interface ClientConfig {
|
||||||
|
gateway_url: string;
|
||||||
|
}
|
||||||
export interface McpServerConfig {
|
export interface McpServerConfig {
|
||||||
name?: string
|
name?: string;
|
||||||
type: 'stdio' | 'streamableHttp' | 'http'
|
type: 'stdio' | 'streamableHttp' | 'http';
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
command?: string
|
command?: string;
|
||||||
args?: string[]
|
args?: string[];
|
||||||
env?: Record<string, string>
|
env?: Record<string, string>;
|
||||||
cwd?: string
|
cwd?: string;
|
||||||
base_url?: string
|
base_url?: string;
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>;
|
||||||
description?: string
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillItem {
|
export interface SkillItem {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
source: string
|
source: string;
|
||||||
path: string
|
path: string;
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillListResponse {
|
export interface SkillListResponse {
|
||||||
skills_system_enabled: boolean
|
skills_system_enabled: boolean;
|
||||||
total: number
|
total: number;
|
||||||
skills: SkillItem[]
|
skills: SkillItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolItem {
|
export interface ToolItem {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
/** "builtin" 或 "mcp:{server_key}" */
|
/** "builtin" 或 "mcp:{server_key}" */
|
||||||
source: string
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolsListResponse {
|
export interface ToolsListResponse {
|
||||||
total: number
|
total: number;
|
||||||
tools: ToolItem[]
|
tools: ToolItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
|
/** GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框) */
|
||||||
export interface ModelOptionsResponse {
|
export interface ModelOptionsResponse {
|
||||||
providers: string[]
|
providers: string[];
|
||||||
models: string[]
|
models: string[];
|
||||||
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
|
/** 当前默认 agent 的 provider/model 名(前端用于在"继承默认"选项旁标注当前生效的模型) */
|
||||||
current: { provider: string, model: string }
|
current: { provider: string; model: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CapabilityPolicy {
|
export interface CapabilityPolicy {
|
||||||
allowed_skills?: string[]
|
allowed_skills?: string[];
|
||||||
denied_skills: string[]
|
denied_skills: string[];
|
||||||
allowed_tools?: string[]
|
allowed_tools?: string[];
|
||||||
denied_tools: string[]
|
denied_tools: string[];
|
||||||
allowed_subagents?: string[]
|
allowed_subagents?: string[];
|
||||||
denied_subagents: string[]
|
denied_subagents: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentItem {
|
export interface SubagentItem {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
source: string
|
source: string;
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[];
|
||||||
capability?: CapabilityPolicy
|
capability?: CapabilityPolicy;
|
||||||
provider?: string
|
provider?: string;
|
||||||
model?: string
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentListResponse {
|
export interface SubagentListResponse {
|
||||||
subagents_system_enabled: boolean
|
subagents_system_enabled: boolean;
|
||||||
total: number
|
total: number;
|
||||||
subagents: SubagentItem[]
|
subagents: SubagentItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExpertsConfig { enabled: boolean; sources: string[] }
|
export interface ExpertsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
sources: string[];
|
||||||
|
}
|
||||||
export interface ExpertItem {
|
export interface ExpertItem {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
source: string
|
source: string;
|
||||||
path?: string
|
path?: string;
|
||||||
body?: string
|
body?: string;
|
||||||
disabled_in_scopes: string[]
|
disabled_in_scopes: string[];
|
||||||
capability?: CapabilityPolicy
|
capability?: CapabilityPolicy;
|
||||||
provider?: string
|
provider?: string;
|
||||||
model?: string
|
model?: string;
|
||||||
}
|
}
|
||||||
export interface ExpertListResponse {
|
export interface ExpertListResponse {
|
||||||
experts_system_enabled: boolean
|
experts_system_enabled: boolean;
|
||||||
total: number
|
total: number;
|
||||||
experts: ExpertItem[]
|
experts: ExpertItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpServerStatus {
|
export interface McpServerStatus {
|
||||||
key: string
|
key: string;
|
||||||
name: string
|
name: string;
|
||||||
transport_type: string
|
transport_type: string;
|
||||||
is_active: boolean
|
is_active: boolean;
|
||||||
connected: boolean
|
connected: boolean;
|
||||||
tool_count: number
|
tool_count: number;
|
||||||
error?: string
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpStatusResponse {
|
export interface McpStatusResponse {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
total_servers: number
|
total_servers: number;
|
||||||
connected_servers: number
|
connected_servers: number;
|
||||||
failed_servers: number
|
failed_servers: number;
|
||||||
total_tools: number
|
total_tools: number;
|
||||||
servers: McpServerStatus[]
|
servers: McpServerStatus[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeishuChannelConfig {
|
export interface FeishuChannelConfig {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
app_id: string
|
app_id: string;
|
||||||
app_secret: string
|
app_secret: string;
|
||||||
allow_from?: string[]
|
allow_from?: string[];
|
||||||
agent?: string
|
agent?: string;
|
||||||
media_dir?: string
|
media_dir?: string;
|
||||||
reaction_emoji?: string
|
reaction_emoji?: string;
|
||||||
max_message_chars?: number
|
max_message_chars?: number;
|
||||||
reply_context_max_chars?: number
|
reply_context_max_chars?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WechatChannelConfig {
|
export interface WechatChannelConfig {
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
base_url: string
|
base_url: string;
|
||||||
cred_path: string
|
cred_path: string;
|
||||||
force_login?: boolean
|
force_login?: boolean;
|
||||||
allow_from?: string[]
|
allow_from?: string[];
|
||||||
agent?: string
|
agent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelConfig {
|
export interface ChannelConfig {
|
||||||
type?: string
|
type?: string;
|
||||||
enabled?: boolean
|
enabled?: boolean;
|
||||||
app_id?: string
|
app_id?: string;
|
||||||
app_secret?: string
|
app_secret?: string;
|
||||||
agent?: string
|
agent?: string;
|
||||||
base_url?: string
|
base_url?: string;
|
||||||
cred_path?: string
|
cred_path?: string;
|
||||||
force_login?: boolean
|
force_login?: boolean;
|
||||||
allow_from?: string[]
|
allow_from?: string[];
|
||||||
media_dir?: string
|
media_dir?: string;
|
||||||
reaction_emoji?: string
|
reaction_emoji?: string;
|
||||||
max_message_chars?: number
|
max_message_chars?: number;
|
||||||
reply_context_max_chars?: number
|
reply_context_max_chars?: number;
|
||||||
[key: string]: unknown
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerJobConfig {
|
export interface SchedulerJobConfig {
|
||||||
id: string
|
id: string;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
kind: string
|
kind: string;
|
||||||
[key: string]: unknown
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
providers: Record<string, ProviderConfig>
|
providers: Record<string, ProviderConfig>;
|
||||||
models: Record<string, ModelConfig>
|
models: Record<string, ModelConfig>;
|
||||||
agents: Record<string, AgentConfig>
|
agents: Record<string, AgentConfig>;
|
||||||
time: TimeConfig
|
time: TimeConfig;
|
||||||
gateway: GatewayConfig
|
gateway: GatewayConfig;
|
||||||
scheduler: SchedulerConfig
|
scheduler: SchedulerConfig;
|
||||||
skills: SkillsConfig
|
skills: SkillsConfig;
|
||||||
tools: ToolsConfig
|
tools: ToolsConfig;
|
||||||
memory_maintenance: MemoryMaintenanceConfig
|
memory_maintenance: MemoryMaintenanceConfig;
|
||||||
image_context: ImageContextConfig
|
image_context: ImageContextConfig;
|
||||||
subagents: SubagentsConfig
|
subagents: SubagentsConfig;
|
||||||
experts: ExpertsConfig
|
experts: ExpertsConfig;
|
||||||
client: ClientConfig
|
client: ClientConfig;
|
||||||
channels: Record<string, ChannelConfig>
|
channels: Record<string, ChannelConfig>;
|
||||||
mcpServers: Record<string, McpServerConfig>
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'experts' | 'mcp' | 'channels'
|
export type TabId =
|
||||||
|
| 'connection'
|
||||||
|
| 'gateway'
|
||||||
|
| 'providers'
|
||||||
|
| 'models'
|
||||||
|
| 'agents'
|
||||||
|
| 'time'
|
||||||
|
| 'scheduler'
|
||||||
|
| 'skills'
|
||||||
|
| 'tools'
|
||||||
|
| 'memory'
|
||||||
|
| 'image'
|
||||||
|
| 'subagents'
|
||||||
|
| 'experts'
|
||||||
|
| 'mcp'
|
||||||
|
| 'channels';
|
||||||
|
|
||||||
export interface ConfigPageProps {
|
export interface ConfigPageProps {
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
onSaveConnection?: (host: string, port: number) => void
|
onSaveConnection?: (host: string, port: number) => void;
|
||||||
initialTab?: TabId
|
initialTab?: TabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnownSource {
|
export interface KnownSource {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
description: string
|
description: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +1,129 @@
|
|||||||
// Shared UI primitives extracted from ConfigPage.tsx
|
// Shared UI primitives extracted from ConfigPage.tsx
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react';
|
||||||
import { X, Plus, Trash2 } from 'lucide-react'
|
import { X, Plus, Trash2 } from 'lucide-react';
|
||||||
import { inputCls } from './constants'
|
import { inputCls } from './constants';
|
||||||
import type { KnownSource } from './types'
|
import type { KnownSource } from './types';
|
||||||
|
|
||||||
export function Field({ label, children, hint }: { label: string; children: ReactNode; hint?: string }) {
|
export function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
|
<label className="block text-[13px] font-medium text-[var(--text-secondary)]">{label}</label>
|
||||||
{children}
|
{children}
|
||||||
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
|
{hint && <p className="text-xs text-[var(--text-muted)]">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
export function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors duration-200 ${checked ? 'bg-[var(--accent-cyan)]' : 'bg-[var(--bg-hover)]'}`}
|
||||||
>
|
>
|
||||||
<span className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
|
export function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) => void }) {
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('');
|
||||||
const add = () => { const v = input.trim(); if (v && !tags.includes(v)) { onChange([...tags, v]); setInput('') } }
|
const add = () => {
|
||||||
|
const v = input.trim();
|
||||||
|
if (v && !tags.includes(v)) {
|
||||||
|
onChange([...tags, v]);
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{tags.map((t, i) => (
|
{tags.map((t, i) => (
|
||||||
<span key={t} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]">
|
<span
|
||||||
|
key={t}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)]"
|
||||||
|
>
|
||||||
{t}
|
{t}
|
||||||
<button onClick={() => onChange(tags.filter((_, j) => j !== i))} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
<button
|
||||||
|
onClick={() => onChange(tags.filter((_, j) => j !== i))}
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input value={input} onChange={e => setInput(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), add())} placeholder="输入后按 Enter" className={inputCls + ' !text-xs'} />
|
<input
|
||||||
<button onClick={add} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs">
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
|
||||||
|
placeholder="输入后按 Enter"
|
||||||
|
className={inputCls + ' !text-xs'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={add}
|
||||||
|
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs"
|
||||||
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) {
|
export function SectionCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
|
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
|
||||||
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
|
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
|
||||||
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
|
{subtitle && (
|
||||||
|
<span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">
|
||||||
|
{subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4">{children}</div>
|
<div className="p-4 space-y-4">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
|
/** 模态框标题栏:图标 + 标题 + X 关闭按钮,与项目模态框惯例一致 */
|
||||||
export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title: string; onClose: () => void }) {
|
export function ModalHeader({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode;
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
<div className="flex items-center gap-3 shrink-0 px-6 py-4 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/50">
|
||||||
{icon}
|
{icon}
|
||||||
@ -78,7 +137,7 @@ export function ModalHeader({ icon, title, onClose }: { icon: ReactNode; title:
|
|||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 模态框底部按钮区,与项目模态框惯例一致 */
|
/** 模态框底部按钮区,与项目模态框惯例一致 */
|
||||||
@ -87,7 +146,7 @@ export function ModalFooter({ children }: { children: ReactNode }) {
|
|||||||
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
|
<div className="shrink-0 px-6 py-3 border-t border-[var(--border-color)] bg-[var(--bg-tertiary)]/30 flex items-center gap-3 justify-end">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourceEditor({
|
export function SourceEditor({
|
||||||
@ -97,41 +156,41 @@ export function SourceEditor({
|
|||||||
examplePaths,
|
examplePaths,
|
||||||
showCustom = true,
|
showCustom = true,
|
||||||
}: {
|
}: {
|
||||||
sources: string[]
|
sources: string[];
|
||||||
onChange: (s: string[]) => void
|
onChange: (s: string[]) => void;
|
||||||
knownSources: KnownSource[]
|
knownSources: KnownSource[];
|
||||||
examplePaths?: string[]
|
examplePaths?: string[];
|
||||||
showCustom?: boolean
|
showCustom?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [customInput, setCustomInput] = useState('')
|
const [customInput, setCustomInput] = useState('');
|
||||||
const knownKeys = new Set(knownSources.map(k => k.key))
|
const knownKeys = new Set(knownSources.map((k) => k.key));
|
||||||
const customPaths = sources.filter(s => !knownKeys.has(s))
|
const customPaths = sources.filter((s) => !knownKeys.has(s));
|
||||||
|
|
||||||
const toggleKnown = (key: string) => {
|
const toggleKnown = (key: string) => {
|
||||||
if (sources.includes(key)) {
|
if (sources.includes(key)) {
|
||||||
onChange(sources.filter(s => s !== key))
|
onChange(sources.filter((s) => s !== key));
|
||||||
} else {
|
} else {
|
||||||
onChange([...sources, key])
|
onChange([...sources, key]);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const addCustom = () => {
|
const addCustom = () => {
|
||||||
const v = customInput.trim()
|
const v = customInput.trim();
|
||||||
if (v && !sources.includes(v)) {
|
if (v && !sources.includes(v)) {
|
||||||
onChange([...sources, v])
|
onChange([...sources, v]);
|
||||||
setCustomInput('')
|
setCustomInput('');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const removeCustom = (path: string) => {
|
const removeCustom = (path: string) => {
|
||||||
onChange(sources.filter(s => s !== path))
|
onChange(sources.filter((s) => s !== path));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Known sources as toggles */}
|
{/* Known sources as toggles */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{knownSources.map(src => (
|
{knownSources.map((src) => (
|
||||||
<div key={src.key} className="flex items-center justify-between py-1.5">
|
<div key={src.key} className="flex items-center justify-between py-1.5">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
|
<div className="text-sm text-[var(--text-primary)]">{src.label}</div>
|
||||||
@ -145,13 +204,23 @@ export function SourceEditor({
|
|||||||
{/* Custom paths (only shown when showCustom is true) */}
|
{/* Custom paths (only shown when showCustom is true) */}
|
||||||
{showCustom && (
|
{showCustom && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">自定义路径</div>
|
<div className="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
||||||
|
自定义路径
|
||||||
|
</div>
|
||||||
{customPaths.length > 0 && (
|
{customPaths.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{customPaths.map((p) => (
|
{customPaths.map((p) => (
|
||||||
<span key={p} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
<span
|
||||||
|
key={p}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
|
||||||
|
>
|
||||||
{p}
|
{p}
|
||||||
<button onClick={() => removeCustom(p)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
<button
|
||||||
|
onClick={() => removeCustom(p)}
|
||||||
|
className="hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -159,95 +228,107 @@ export function SourceEditor({
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={customInput}
|
value={customInput}
|
||||||
onChange={e => setCustomInput(e.target.value)}
|
onChange={(e) => setCustomInput(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addCustom())}
|
||||||
placeholder="输入绝对路径,如 D:\my-skills"
|
placeholder="输入绝对路径,如 D:\my-skills"
|
||||||
className={inputCls + ' !text-xs font-mono'}
|
className={inputCls + ' !text-xs font-mono'}
|
||||||
/>
|
/>
|
||||||
<button onClick={addCustom} className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0">
|
<button
|
||||||
|
onClick={addCustom}
|
||||||
|
className="px-2 py-1 rounded-lg bg-[var(--accent-cyan)]/10 text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/20 transition-colors text-xs shrink-0"
|
||||||
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{examplePaths && (
|
{examplePaths && (
|
||||||
<p className="text-xs text-[var(--text-muted)]">
|
<p className="text-xs text-[var(--text-muted)]">示例: {examplePaths.join('、')}</p>
|
||||||
示例: {examplePaths.join('、')}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CheckboxListOption {
|
export interface CheckboxListOption {
|
||||||
key: string
|
key: string;
|
||||||
label: string
|
label: string;
|
||||||
description?: string
|
description?: string;
|
||||||
/** 可选分组标识,配合 groupBy 使用 */
|
/** 可选分组标识,配合 groupBy 使用 */
|
||||||
group?: string
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckboxListProps {
|
interface CheckboxListProps {
|
||||||
options: CheckboxListOption[]
|
options: CheckboxListOption[];
|
||||||
selected: string[]
|
selected: string[];
|
||||||
onChange: (selected: string[]) => void
|
onChange: (selected: string[]) => void;
|
||||||
/** 未在 options 中出现但已选中的值(legacy 数据),以可移除标签形式展示 */
|
/** 未在 options 中出现但已选中的值(legacy 数据),以可移除标签形式展示 */
|
||||||
extraSelected?: string[]
|
extraSelected?: string[];
|
||||||
emptyHint?: string
|
emptyHint?: string;
|
||||||
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx") */
|
/** 可选:按返回的分组名分组展示(如 "builtin" / "mcp:xxx") */
|
||||||
groupBy?: (option: CheckboxListOption) => string
|
groupBy?: (option: CheckboxListOption) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
|
* 通用勾选列表组件:用 Toggle 切换每个预设选项;额外的 legacy 已选值以可移除标签展示。
|
||||||
*/
|
*/
|
||||||
export function CheckboxList({ options, selected, onChange, extraSelected = [], emptyHint, groupBy }: CheckboxListProps) {
|
export function CheckboxList({
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
extraSelected = [],
|
||||||
|
emptyHint,
|
||||||
|
groupBy,
|
||||||
|
}: CheckboxListProps) {
|
||||||
const toggle = (key: string) =>
|
const toggle = (key: string) =>
|
||||||
onChange(selected.includes(key) ? selected.filter(k => k !== key) : [...selected, key])
|
onChange(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
|
||||||
|
|
||||||
const selectedSet = new Set(selected)
|
const selectedSet = new Set(selected);
|
||||||
const optionsInList = new Set(options.map(o => o.key))
|
const optionsInList = new Set(options.map((o) => o.key));
|
||||||
// 仅展示未出现在 options 中的额外已选值
|
// 仅展示未出现在 options 中的额外已选值
|
||||||
const extra = extraSelected.filter(k => !optionsInList.has(k))
|
const extra = extraSelected.filter((k) => !optionsInList.has(k));
|
||||||
|
|
||||||
// 分组渲染
|
// 分组渲染
|
||||||
const renderOptions = (opts: CheckboxListOption[]) => (
|
const renderOptions = (opts: CheckboxListOption[]) => (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{opts.map(option => (
|
{opts.map((option) => (
|
||||||
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
|
<div key={option.key} className="flex items-center justify-between py-1.5 gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
|
<div className="text-sm text-[var(--text-primary)] font-mono">{option.label}</div>
|
||||||
{option.description && <div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>}
|
{option.description && (
|
||||||
|
<div className="text-xs text-[var(--text-muted)] truncate">{option.description}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
|
<Toggle checked={selectedSet.has(option.key)} onChange={() => toggle(option.key)} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
let body: ReactNode
|
let body: ReactNode;
|
||||||
if (options.length === 0) {
|
if (options.length === 0) {
|
||||||
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>
|
body = <p className="text-xs text-[var(--text-muted)]">{emptyHint || '无可用选项'}</p>;
|
||||||
} else if (groupBy) {
|
} else if (groupBy) {
|
||||||
const groups = new Map<string, CheckboxListOption[]>()
|
const groups = new Map<string, CheckboxListOption[]>();
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const g = groupBy(opt)
|
const g = groupBy(opt);
|
||||||
const arr = groups.get(g) ?? []
|
const arr = groups.get(g) ?? [];
|
||||||
arr.push(opt)
|
arr.push(opt);
|
||||||
groups.set(g, arr)
|
groups.set(g, arr);
|
||||||
}
|
}
|
||||||
body = (
|
body = (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{Array.from(groups.entries()).map(([g, opts]) => (
|
{Array.from(groups.entries()).map(([g, opts]) => (
|
||||||
<div key={g}>
|
<div key={g}>
|
||||||
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">{g}</div>
|
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
|
||||||
|
{g}
|
||||||
|
</div>
|
||||||
{renderOptions(opts)}
|
{renderOptions(opts)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
} else {
|
} else {
|
||||||
body = renderOptions(options)
|
body = renderOptions(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -255,33 +336,70 @@ export function CheckboxList({ options, selected, onChange, extraSelected = [],
|
|||||||
{body}
|
{body}
|
||||||
{extra.length > 0 && (
|
{extra.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">其他已选(不在当前选项中)</div>
|
<div className="text-[10px] font-medium text-[var(--text-muted)] uppercase tracking-wider mb-1">
|
||||||
|
其他已选(不在当前选项中)
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{extra.map(key => (
|
{extra.map((key) => (
|
||||||
<span key={key} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono">
|
<span
|
||||||
|
key={key}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-[var(--accent-cyan)]/10 border border-[var(--accent-cyan)]/20 text-xs text-[var(--accent-cyan)] font-mono"
|
||||||
|
>
|
||||||
{key}
|
{key}
|
||||||
<button onClick={() => toggle(key)} className="hover:text-white transition-colors"><X className="h-3 w-3" /></button>
|
<button onClick={() => toggle(key)} className="hover:text-white transition-colors">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: () => void; onRename?: (n: string) => void }) {
|
export function MapEntryHeader({
|
||||||
const [editing, setEditing] = useState(false)
|
name,
|
||||||
const [val, setVal] = useState(name)
|
onDelete,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
onDelete: () => void;
|
||||||
|
onRename?: (n: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [val, setVal] = useState(name);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
|
<div className="flex items-center gap-2 px-4 py-2 bg-[var(--bg-tertiary)]/50 border-b border-[var(--border-color)]">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input value={val} onChange={e => setVal(e.target.value)} onBlur={() => { setEditing(false); onRename?.(val.trim() || name) }} onKeyDown={e => e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))} className={inputCls + ' !py-1 !text-xs max-w-[200px]'} autoFocus />
|
<input
|
||||||
|
value={val}
|
||||||
|
onChange={(e) => setVal(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
setEditing(false);
|
||||||
|
onRename?.(val.trim() || name);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
e.key === 'Enter' && (setEditing(false), onRename?.(val.trim() || name))
|
||||||
|
}
|
||||||
|
className={inputCls + ' !py-1 !text-xs max-w-[200px]'}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer" onClick={() => onRename && setEditing(true)}>{name}</span>
|
<span
|
||||||
|
className="text-sm font-mono text-[var(--accent-cyan)] cursor-pointer"
|
||||||
|
onClick={() => onRename && setEditing(true)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button onClick={onDelete} className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"><Trash2 className="h-3.5 w-3.5" /></button>
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 rounded text-red-400/60 hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react'
|
import { Clock, RefreshCw, ChevronRight, Check, X, Minus } from 'lucide-react';
|
||||||
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol'
|
import type { SchedulerJobSummary, SchedulerJobSessionLookup } from '../../types/protocol';
|
||||||
|
|
||||||
interface SchedulerJobListProps {
|
interface SchedulerJobListProps {
|
||||||
jobs: SchedulerJobSummary[]
|
jobs: SchedulerJobSummary[];
|
||||||
onRefresh: () => void
|
onRefresh: () => void;
|
||||||
onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void
|
onViewJob: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => void;
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function kindLabel(kind: string): string {
|
function kindLabel(kind: string): string {
|
||||||
@ -14,61 +14,81 @@ function kindLabel(kind: string): string {
|
|||||||
outbound_message: '外发消息',
|
outbound_message: '外发消息',
|
||||||
agent_task: '智能体',
|
agent_task: '智能体',
|
||||||
silent_agent_task: '静默智能体',
|
silent_agent_task: '静默智能体',
|
||||||
}
|
};
|
||||||
return map[kind] ?? kind
|
return map[kind] ?? kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateBadge(state: string): { label: string; color: string; pulse: boolean } {
|
function stateBadge(state: string): { label: string; color: string; pulse: boolean } {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return { label: '执行中', color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', pulse: true }
|
return {
|
||||||
|
label: '执行中',
|
||||||
|
color: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||||
|
pulse: true,
|
||||||
|
};
|
||||||
case 'scheduled':
|
case 'scheduled':
|
||||||
return { label: '已调度', color: 'bg-amber-500/15 text-amber-300 border-amber-500/20', pulse: false }
|
return {
|
||||||
|
label: '已调度',
|
||||||
|
color: 'bg-amber-500/15 text-amber-300 border-amber-500/20',
|
||||||
|
pulse: false,
|
||||||
|
};
|
||||||
case 'paused':
|
case 'paused':
|
||||||
return { label: '已暂停', color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false }
|
return {
|
||||||
|
label: '已暂停',
|
||||||
|
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
|
||||||
|
pulse: false,
|
||||||
|
};
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return { label: '已完成', color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', pulse: false }
|
return {
|
||||||
|
label: '已完成',
|
||||||
|
color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||||
|
pulse: false,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return { label: state, color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', pulse: false }
|
return {
|
||||||
|
label: state,
|
||||||
|
color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30',
|
||||||
|
pulse: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(tsMillis: number | undefined): string {
|
function formatTime(tsMillis: number | undefined): string {
|
||||||
if (tsMillis == null) return '--'
|
if (tsMillis == null) return '--';
|
||||||
const d = new Date(tsMillis)
|
const d = new Date(tsMillis);
|
||||||
return d.toLocaleString('zh-CN', {
|
return d.toLocaleString('zh-CN', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleDescription(schedule: unknown): string {
|
function scheduleDescription(schedule: unknown): string {
|
||||||
if (!schedule || typeof schedule !== 'object') return '--'
|
if (!schedule || typeof schedule !== 'object') return '--';
|
||||||
const s = schedule as Record<string, unknown>
|
const s = schedule as Record<string, unknown>;
|
||||||
switch (s.type) {
|
switch (s.type) {
|
||||||
case 'cron':
|
case 'cron':
|
||||||
return `Cron: ${s.expression}`
|
return `Cron: ${s.expression}`;
|
||||||
case 'interval':
|
case 'interval':
|
||||||
return `每 ${s.seconds} 秒`
|
return `每 ${s.seconds} 秒`;
|
||||||
case 'delay':
|
case 'delay':
|
||||||
return `延迟 ${s.seconds} 秒`
|
return `延迟 ${s.seconds} 秒`;
|
||||||
case 'at':
|
case 'at':
|
||||||
return `定时: ${s.timestamp}`
|
return `定时: ${s.timestamp}`;
|
||||||
default:
|
default:
|
||||||
return JSON.stringify(s)
|
return JSON.stringify(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function lastStatusIcon(lastStatus: string | undefined) {
|
function lastStatusIcon(lastStatus: string | undefined) {
|
||||||
switch (lastStatus) {
|
switch (lastStatus) {
|
||||||
case 'ok':
|
case 'ok':
|
||||||
return <Check className="h-3 w-3 text-emerald-400" />
|
return <Check className="h-3 w-3 text-emerald-400" />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <X className="h-3 w-3 text-red-400" />
|
return <X className="h-3 w-3 text-red-400" />;
|
||||||
default:
|
default:
|
||||||
return <Minus className="h-3 w-3 text-[var(--text-muted)]" />
|
return <Minus className="h-3 w-3 text-[var(--text-muted)]" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,14 +128,14 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{jobs.map((job) => {
|
{jobs.map((job) => {
|
||||||
const badge = stateBadge(job.state)
|
const badge = stateBadge(job.state);
|
||||||
const hasLookup = !!job.session_lookup
|
const hasLookup = !!job.session_lookup;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={job.id}
|
key={job.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (hasLookup && job.session_lookup) {
|
if (hasLookup && job.session_lookup) {
|
||||||
onViewJob(job.session_lookup, job.id, job.id)
|
onViewJob(job.session_lookup, job.id, job.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!hasLookup}
|
disabled={!hasLookup}
|
||||||
@ -136,26 +156,43 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`} />
|
<span
|
||||||
{hasLookup && <ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />}
|
className={`inline-block h-1.5 w-1.5 rounded-full ${job.enabled ? 'bg-emerald-400' : 'bg-zinc-600'}`}
|
||||||
|
/>
|
||||||
|
{hasLookup && (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-[var(--text-muted)]" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: State badge + last status */}
|
{/* Row 2: State badge + last status */}
|
||||||
<div className="flex items-center gap-2 mb-1.5">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}>
|
<span
|
||||||
{badge.pulse && <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />}
|
className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded border ${badge.color}`}
|
||||||
|
>
|
||||||
|
{badge.pulse && (
|
||||||
|
<span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||||
|
)}
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-0.5 text-xs text-[var(--text-muted)]">
|
<span className="inline-flex items-center gap-0.5 text-xs text-[var(--text-muted)]">
|
||||||
{lastStatusIcon(job.last_status)}
|
{lastStatusIcon(job.last_status)}
|
||||||
<span className={
|
<span
|
||||||
job.last_status === 'error' ? 'text-red-400' :
|
className={
|
||||||
job.last_status === 'ok' ? 'text-emerald-400' : 'text-[var(--text-muted)]'
|
job.last_status === 'error'
|
||||||
}>
|
? 'text-red-400'
|
||||||
{job.last_status === 'ok' ? '正常' :
|
: job.last_status === 'ok'
|
||||||
job.last_status === 'error' ? '异常' :
|
? 'text-emerald-400'
|
||||||
job.last_status === 'skipped' ? '跳过' : '--'}
|
: 'text-[var(--text-muted)]'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{job.last_status === 'ok'
|
||||||
|
? '正常'
|
||||||
|
: job.last_status === 'error'
|
||||||
|
? '异常'
|
||||||
|
: job.last_status === 'skipped'
|
||||||
|
? '跳过'
|
||||||
|
: '--'}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -170,7 +207,8 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
|
|||||||
{/* Row 4: Run count + schedule */}
|
{/* Row 4: Run count + schedule */}
|
||||||
<div className="flex items-center justify-between text-xs text-[var(--text-muted)] mb-1">
|
<div className="flex items-center justify-between text-xs text-[var(--text-muted)] mb-1">
|
||||||
<span>
|
<span>
|
||||||
执行: {job.run_count}{job.max_runs ? `/${job.max_runs}` : ''}
|
执行: {job.run_count}
|
||||||
|
{job.max_runs ? `/${job.max_runs}` : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate max-w-[120px] text-[var(--text-muted)]">
|
<span className="truncate max-w-[120px] text-[var(--text-muted)]">
|
||||||
{scheduleDescription(job.schedule)}
|
{scheduleDescription(job.schedule)}
|
||||||
@ -183,11 +221,11 @@ export function SchedulerJobList({ jobs, onRefresh, onViewJob, sessionId }: Sche
|
|||||||
<span>下次: {formatTime(job.next_fire_at)}</span>
|
<span>下次: {formatTime(job.next_fire_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Wifi, FolderOpen, Hash } from 'lucide-react'
|
import { Wifi, FolderOpen, Hash } from 'lucide-react';
|
||||||
import type { Session } from '../../types/protocol'
|
import type { Session } from '../../types/protocol';
|
||||||
|
|
||||||
interface SessionInfoProps {
|
interface SessionInfoProps {
|
||||||
session: Session | null
|
session: Session | null;
|
||||||
connectionId: string | null
|
connectionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionInfo({ session, connectionId }: SessionInfoProps) {
|
export function SessionInfo({ session, connectionId }: SessionInfoProps) {
|
||||||
@ -20,7 +20,9 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
|
|||||||
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/80 p-3">
|
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/80 p-3">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<FolderOpen className="h-4 w-4 text-[var(--text-secondary)]" />
|
<FolderOpen className="h-4 w-4 text-[var(--text-secondary)]" />
|
||||||
<span className="text-xs text-[var(--text-muted)] uppercase tracking-wider">当前会话</span>
|
<span className="text-xs text-[var(--text-muted)] uppercase tracking-wider">
|
||||||
|
当前会话
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{session ? (
|
{session ? (
|
||||||
@ -46,5 +48,5 @@ export function SessionInfo({ session, connectionId }: SessionInfoProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,32 +1,45 @@
|
|||||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { Plus, MessageSquare, Layers, Hash, Clock, RefreshCw, Trash2, Check, X, ChevronLeft, ChevronRight, Edit2 } from 'lucide-react'
|
import {
|
||||||
import type { Topic } from '../../types/protocol'
|
Plus,
|
||||||
|
MessageSquare,
|
||||||
|
Layers,
|
||||||
|
Hash,
|
||||||
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Edit2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { Topic } from '../../types/protocol';
|
||||||
|
|
||||||
interface TopicListProps {
|
interface TopicListProps {
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
topics: Topic[]
|
topics: Topic[];
|
||||||
currentTopicId: string | null
|
currentTopicId: string | null;
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean;
|
||||||
onCreateTopic: () => void
|
onCreateTopic: () => void;
|
||||||
onRefresh: () => void
|
onRefresh: () => void;
|
||||||
onSwitchTopic: (topicId: string) => void
|
onSwitchTopic: (topicId: string) => void;
|
||||||
onDeleteTopic: (topicId: string) => void
|
onDeleteTopic: (topicId: string) => void;
|
||||||
onRenameTopic: (topicId: string, title: string) => void
|
onRenameTopic: (topicId: string, title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
function formatTime(timestamp: number): string {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp);
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24))
|
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (diffDays === 0) {
|
if (diffDays === 0) {
|
||||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||||
} else if (diffDays === 1) {
|
} else if (diffDays === 1) {
|
||||||
return '昨天'
|
return '昨天';
|
||||||
} else if (diffDays < 7) {
|
} else if (diffDays < 7) {
|
||||||
return `${diffDays}天前`
|
return `${diffDays}天前`;
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,79 +54,79 @@ export function TopicList({
|
|||||||
onDeleteTopic,
|
onDeleteTopic,
|
||||||
onRenameTopic,
|
onRenameTopic,
|
||||||
}: TopicListProps) {
|
}: TopicListProps) {
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
const [editingTopicId, setEditingTopicId] = useState<string | null>(null)
|
const [editingTopicId, setEditingTopicId] = useState<string | null>(null);
|
||||||
const [editingTitle, setEditingTitle] = useState('')
|
const [editingTitle, setEditingTitle] = useState('');
|
||||||
const editInputRef = useRef<HTMLInputElement>(null)
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 进入编辑模式时自动聚焦 input
|
// 进入编辑模式时自动聚焦 input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingTopicId && editInputRef.current) {
|
if (editingTopicId && editInputRef.current) {
|
||||||
editInputRef.current.focus()
|
editInputRef.current.focus();
|
||||||
editInputRef.current.select()
|
editInputRef.current.select();
|
||||||
}
|
}
|
||||||
}, [editingTopicId])
|
}, [editingTopicId]);
|
||||||
|
|
||||||
const startEdit = useCallback((topic: Topic) => {
|
const startEdit = useCallback((topic: Topic) => {
|
||||||
setConfirmDeleteId(null)
|
setConfirmDeleteId(null);
|
||||||
setEditingTopicId(topic.id)
|
setEditingTopicId(topic.id);
|
||||||
setEditingTitle(topic.title)
|
setEditingTitle(topic.title);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const cancelEdit = useCallback(() => {
|
const cancelEdit = useCallback(() => {
|
||||||
setEditingTopicId(null)
|
setEditingTopicId(null);
|
||||||
setEditingTitle('')
|
setEditingTitle('');
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const commitEdit = useCallback(() => {
|
const commitEdit = useCallback(() => {
|
||||||
const trimmed = editingTitle.trim()
|
const trimmed = editingTitle.trim();
|
||||||
if (!trimmed || !editingTopicId) {
|
if (!trimmed || !editingTopicId) {
|
||||||
cancelEdit()
|
cancelEdit();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
onRenameTopic(editingTopicId, trimmed)
|
onRenameTopic(editingTopicId, trimmed);
|
||||||
setEditingTopicId(null)
|
setEditingTopicId(null);
|
||||||
setEditingTitle('')
|
setEditingTitle('');
|
||||||
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit])
|
}, [editingTitle, editingTopicId, onRenameTopic, cancelEdit]);
|
||||||
|
|
||||||
// Pagination — dynamically sized to fill one screen without scrolling
|
// Pagination — dynamically sized to fill one screen without scrolling
|
||||||
const ESTIMATED_ITEM_HEIGHT = 64 // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
|
const ESTIMATED_ITEM_HEIGHT = 64; // py-3(24px) + title(20px) + mt-1.5(6px) + meta(14px)
|
||||||
const LIST_PADDING = 24 // p-3 top + bottom
|
const LIST_PADDING = 24; // p-3 top + bottom
|
||||||
const [pageSize, setPageSize] = useState(8) // fallback before measurement
|
const [pageSize, setPageSize] = useState(8); // fallback before measurement
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const measurePageSize = useCallback(() => {
|
const measurePageSize = useCallback(() => {
|
||||||
const el = listRef.current
|
const el = listRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
const available = el.clientHeight - LIST_PADDING
|
const available = el.clientHeight - LIST_PADDING;
|
||||||
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1))
|
setPageSize(Math.max(1, Math.floor(available / ESTIMATED_ITEM_HEIGHT) - 1));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
measurePageSize()
|
measurePageSize();
|
||||||
const el = listRef.current
|
const el = listRef.current;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
const observer = new ResizeObserver(() => measurePageSize())
|
const observer = new ResizeObserver(() => measurePageSize());
|
||||||
observer.observe(el)
|
observer.observe(el);
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect();
|
||||||
}, [measurePageSize])
|
}, [measurePageSize]);
|
||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = useMemo(
|
||||||
() => Math.max(1, Math.ceil(topics.length / pageSize)),
|
() => Math.max(1, Math.ceil(topics.length / pageSize)),
|
||||||
[topics.length, pageSize]
|
[topics.length, pageSize],
|
||||||
)
|
);
|
||||||
const pagedTopics = useMemo(
|
const pagedTopics = useMemo(
|
||||||
() => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize),
|
() => topics.slice(currentPage * pageSize, (currentPage + 1) * pageSize),
|
||||||
[topics, currentPage, pageSize]
|
[topics, currentPage, pageSize],
|
||||||
)
|
);
|
||||||
|
|
||||||
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
|
// Clamp currentPage when it exceeds totalPages (e.g., after deletion on last page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentPage >= totalPages) {
|
if (currentPage >= totalPages) {
|
||||||
setCurrentPage(Math.max(0, totalPages - 1))
|
setCurrentPage(Math.max(0, totalPages - 1));
|
||||||
}
|
}
|
||||||
}, [currentPage, totalPages])
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@ -178,8 +191,8 @@ export function TopicList({
|
|||||||
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
// 按钮使用 onMouseDown preventDefault 防止 input blur 提前触发
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
commitEdit()
|
commitEdit();
|
||||||
}}
|
}}
|
||||||
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
|
className="w-full rounded-xl pl-3 pr-1.5 py-2 flex items-center gap-2 bg-[var(--bg-tertiary)] border border-[var(--accent-cyan)]/40"
|
||||||
>
|
>
|
||||||
@ -189,8 +202,8 @@ export function TopicList({
|
|||||||
onChange={(e) => setEditingTitle(e.target.value)}
|
onChange={(e) => setEditingTitle(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
cancelEdit()
|
cancelEdit();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={cancelEdit}
|
onBlur={cancelEdit}
|
||||||
@ -231,9 +244,13 @@ export function TopicList({
|
|||||||
{currentPage * pageSize + index + 1}
|
{currentPage * pageSize + index + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className={`truncate font-medium ${
|
<div
|
||||||
topic.id === currentTopicId ? 'text-[var(--accent-cyan)]' : 'text-[var(--text-secondary)]'
|
className={`truncate font-medium ${
|
||||||
}`}>
|
topic.id === currentTopicId
|
||||||
|
? 'text-[var(--accent-cyan)]'
|
||||||
|
: 'text-[var(--text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{topic.description || topic.title}
|
{topic.description || topic.title}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1.5">
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
@ -260,9 +277,9 @@ export function TopicList({
|
|||||||
<span className="text-xs text-red-400 whitespace-nowrap">确认删除?</span>
|
<span className="text-xs text-red-400 whitespace-nowrap">确认删除?</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
onDeleteTopic(topic.id)
|
onDeleteTopic(topic.id);
|
||||||
setConfirmDeleteId(null)
|
setConfirmDeleteId(null);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
className="flex items-center justify-center h-5 w-5 rounded bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
title="确认"
|
title="确认"
|
||||||
@ -271,8 +288,8 @@ export function TopicList({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setConfirmDeleteId(null)
|
setConfirmDeleteId(null);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
|
className="flex items-center justify-center h-5 w-5 rounded bg-zinc-500/20 text-zinc-400 hover:bg-zinc-500/30 transition-colors"
|
||||||
title="取消"
|
title="取消"
|
||||||
@ -284,8 +301,8 @@ export function TopicList({
|
|||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
startEdit(topic)
|
startEdit(topic);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
|
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-[var(--accent-cyan)] hover:bg-[var(--accent-cyan)]/10 transition-colors"
|
||||||
title="重命名话题"
|
title="重命名话题"
|
||||||
@ -294,8 +311,8 @@ export function TopicList({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation();
|
||||||
setConfirmDeleteId(topic.id)
|
setConfirmDeleteId(topic.id);
|
||||||
}}
|
}}
|
||||||
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
className="flex items-center justify-center h-6 w-6 rounded-md text-[var(--text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
title="删除话题"
|
title="删除话题"
|
||||||
@ -317,7 +334,7 @@ export function TopicList({
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
|
<div className="flex items-center justify-center gap-2 border-t border-[var(--border-color)] px-3 py-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
|
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={currentPage === 0}
|
disabled={currentPage === 0}
|
||||||
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
@ -327,7 +344,7 @@ export function TopicList({
|
|||||||
{currentPage + 1} / {totalPages}
|
{currentPage + 1} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={currentPage >= totalPages - 1}
|
disabled={currentPage >= totalPages - 1}
|
||||||
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
className="flex items-center justify-center h-7 w-7 rounded-md text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--overlay-subtle)] disabled:text-[var(--text-muted)] disabled:cursor-not-allowed transition-all"
|
||||||
>
|
>
|
||||||
@ -336,5 +353,5 @@ export function TopicList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,42 +9,46 @@ import type {
|
|||||||
StreamEnd,
|
StreamEnd,
|
||||||
ExecutionCompleted,
|
ExecutionCompleted,
|
||||||
WsError,
|
WsError,
|
||||||
} from '../../types/protocol'
|
} from '../../types/protocol';
|
||||||
|
|
||||||
// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref)
|
// 模块级消息 ID 计数器,保证全局唯一(原 useRef 实现,提升为模块级消除 hook 内部 ref)
|
||||||
let messageIdCounter = 0
|
let messageIdCounter = 0;
|
||||||
|
|
||||||
export function generateMessageId(): string {
|
export function generateMessageId(): string {
|
||||||
messageIdCounter += 1
|
messageIdCounter += 1;
|
||||||
return `msg_${Date.now()}_${messageIdCounter}`
|
return `msg_${Date.now()}_${messageIdCounter}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置计数器(仅测试使用) */
|
/** 重置计数器(仅测试使用) */
|
||||||
export function _resetMessageIdCounterForTests(): void {
|
export function _resetMessageIdCounterForTests(): void {
|
||||||
messageIdCounter = 0
|
messageIdCounter = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从服务端消息中提取 subagent_task_id(如果该消息类型携带此字段) */
|
/** 从服务端消息中提取 subagent_task_id(如果该消息类型携带此字段) */
|
||||||
export function getSubagentTaskId(message: WsOutbound): string | undefined {
|
export function getSubagentTaskId(message: WsOutbound): string | undefined {
|
||||||
if (message.type === 'tool_call' || message.type === 'tool_result'
|
if (
|
||||||
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
|
message.type === 'tool_call' ||
|
||||||
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id
|
message.type === 'tool_result' ||
|
||||||
|
message.type === 'tool_pending' ||
|
||||||
|
message.type === 'assistant_response'
|
||||||
|
) {
|
||||||
|
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).subagent_task_id;
|
||||||
}
|
}
|
||||||
if (message.type === 'stream_delta' || message.type === 'stream_end') {
|
if (message.type === 'stream_delta' || message.type === 'stream_end') {
|
||||||
return (message as StreamDelta | StreamEnd).subagent_task_id
|
return (message as StreamDelta | StreamEnd).subagent_task_id;
|
||||||
}
|
}
|
||||||
if (message.type === 'execution_completed' || message.type === 'error') {
|
if (message.type === 'execution_completed' || message.type === 'error') {
|
||||||
return (message as ExecutionCompleted | WsError).subagent_task_id
|
return (message as ExecutionCompleted | WsError).subagent_task_id;
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将服务端消息转换为 UI ChatMessage;不兼容的消息类型返回 null */
|
/** 将服务端消息转换为 UI ChatMessage;不兼容的消息类型返回 null */
|
||||||
export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null {
|
export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | null {
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'assistant_response': {
|
case 'assistant_response': {
|
||||||
const msg = message as AssistantResponse
|
const msg = message as AssistantResponse;
|
||||||
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
|
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: role as ChatMessage['role'],
|
role: role as ChatMessage['role'],
|
||||||
@ -54,10 +58,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
attachments: msg.attachments,
|
attachments: msg.attachments,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
reasoningContent: msg.reasoning_content,
|
reasoningContent: msg.reasoning_content,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case 'tool_call': {
|
case 'tool_call': {
|
||||||
const msg = message as ToolCall
|
const msg = message as ToolCall;
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@ -69,10 +73,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
arguments: msg.arguments,
|
arguments: msg.arguments,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
reasoningContent: msg.reasoning_content,
|
reasoningContent: msg.reasoning_content,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
const msg = message as ToolResult
|
const msg = message as ToolResult;
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@ -83,10 +87,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
durationMs: msg.duration_ms,
|
durationMs: msg.duration_ms,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case 'tool_pending': {
|
case 'tool_pending': {
|
||||||
const msg = message as ToolPending
|
const msg = message as ToolPending;
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@ -96,10 +100,10 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
toolName: msg.tool_name,
|
toolName: msg.tool_name,
|
||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case 'stream_delta': {
|
case 'stream_delta': {
|
||||||
const msg = message as StreamDelta
|
const msg = message as StreamDelta;
|
||||||
return {
|
return {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'assistant' as const,
|
role: 'assistant' as const,
|
||||||
@ -108,7 +112,7 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
type: 'message' as const,
|
type: 'message' as const,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
reasoningContent: msg.reasoning_delta,
|
reasoningContent: msg.reasoning_delta,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
case 'error': {
|
case 'error': {
|
||||||
return {
|
return {
|
||||||
@ -117,9 +121,9 @@ export function serverMessageToChatMessage(message: WsOutbound): ChatMessage | n
|
|||||||
content: `Error: ${message.message}`,
|
content: `Error: ${message.message}`,
|
||||||
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import type { ChatMessage } from '../../types/protocol'
|
import type { ChatMessage } from '../../types/protocol';
|
||||||
|
|
||||||
/** 子智能体视图(栈中的一层) */
|
/** 子智能体视图(栈中的一层) */
|
||||||
export interface SubAgentView {
|
export interface SubAgentView {
|
||||||
taskId: string
|
taskId: string;
|
||||||
description: string
|
description: string;
|
||||||
subagentType: string
|
subagentType: string;
|
||||||
status: string
|
status: string;
|
||||||
summary?: string
|
summary?: string;
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 定时任务执行对话查看视图 */
|
/** 定时任务执行对话查看视图 */
|
||||||
export interface SchedulerJobView {
|
export interface SchedulerJobView {
|
||||||
jobId: string
|
jobId: string;
|
||||||
description: string
|
description: string;
|
||||||
channel: string
|
channel: string;
|
||||||
chatId: string
|
chatId: string;
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CHAT_ID = 'default'
|
export const DEFAULT_CHAT_ID = 'default';
|
||||||
|
|||||||
@ -1,28 +1,28 @@
|
|||||||
import { useState, useCallback, useMemo, useRef } from 'react'
|
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import type { WsInbound, Command } from '../../types/protocol'
|
import type { WsInbound, Command } from '../../types/protocol';
|
||||||
|
|
||||||
export interface UseConnectionReturn {
|
export interface UseConnectionReturn {
|
||||||
connectionId: string | null
|
connectionId: string | null;
|
||||||
isConnected: boolean
|
isConnected: boolean;
|
||||||
setConnectionId: (id: string | null) => void
|
setConnectionId: (id: string | null) => void;
|
||||||
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
|
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
|
||||||
/** 发送命令到后端(封装 command payload 序列化) */
|
/** 发送命令到后端(封装 command payload 序列化) */
|
||||||
sendCommand: (cmd: Command) => void
|
sendCommand: (cmd: Command) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConnection(): UseConnectionReturn {
|
export function useConnection(): UseConnectionReturn {
|
||||||
const [connectionId, setConnectionId] = useState<string | null>(null)
|
const [connectionId, setConnectionId] = useState<string | null>(null);
|
||||||
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
|
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null);
|
||||||
|
|
||||||
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
|
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
|
||||||
sendMessageRef.current = fn
|
sendMessageRef.current = fn;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const sendCommand = useCallback((cmd: Command) => {
|
const sendCommand = useCallback((cmd: Command) => {
|
||||||
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) })
|
sendMessageRef.current?.({ type: 'command', payload: JSON.stringify(cmd) });
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const isConnected = useMemo(() => connectionId !== null, [connectionId])
|
const isConnected = useMemo(() => connectionId !== null, [connectionId]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId,
|
connectionId,
|
||||||
@ -30,5 +30,5 @@ export function useConnection(): UseConnectionReturn {
|
|||||||
setConnectionId,
|
setConnectionId,
|
||||||
setSendMessage,
|
setSendMessage,
|
||||||
sendCommand,
|
sendCommand,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { useState, useCallback, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
type MutableRefObject,
|
||||||
|
} from 'react';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
WsOutbound,
|
WsOutbound,
|
||||||
@ -13,48 +20,48 @@ import type {
|
|||||||
TaskStarted,
|
TaskStarted,
|
||||||
Attachment,
|
Attachment,
|
||||||
Command,
|
Command,
|
||||||
} from '../../types/protocol'
|
} from '../../types/protocol';
|
||||||
import { generateMessageId, getSubagentTaskId } from './messageMappers'
|
import { generateMessageId, getSubagentTaskId } from './messageMappers';
|
||||||
|
|
||||||
interface UseMessagesOptions {
|
interface UseMessagesOptions {
|
||||||
selectedTopicRef: MutableRefObject<string | null>
|
selectedTopicRef: MutableRefObject<string | null>;
|
||||||
topicsRef: MutableRefObject<Topic[]>
|
topicsRef: MutableRefObject<Topic[]>;
|
||||||
bumpTopicRefreshTrigger: () => void
|
bumpTopicRefreshTrigger: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseMessagesReturn {
|
export interface UseMessagesReturn {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>
|
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
setIsLoading: Dispatch<SetStateAction<boolean>>
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||||
handleMessage: (content: string, attachments?: Attachment[]) => void
|
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||||||
clearMessages: () => void
|
clearMessages: () => void;
|
||||||
handleStop: () => Command
|
handleStop: () => Command;
|
||||||
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
/** 处理主视图的消息类 case(task_started, stream_*, tool_*, execution_*, error),返回是否已处理 */
|
||||||
handleMainViewMessage: (message: WsOutbound) => boolean
|
handleMainViewMessage: (message: WsOutbound) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
||||||
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options
|
const { selectedTopicRef, topicsRef, bumpTopicRefreshTrigger } = options;
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set())
|
const syncedUserMessageIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const applyUserMessageId = useCallback((userMessageId: string) => {
|
const applyUserMessageId = useCallback((userMessageId: string) => {
|
||||||
if (syncedUserMessageIdsRef.current.has(userMessageId)) return
|
if (syncedUserMessageIdsRef.current.has(userMessageId)) return;
|
||||||
syncedUserMessageIdsRef.current.add(userMessageId)
|
syncedUserMessageIdsRef.current.add(userMessageId);
|
||||||
setMessages(prev => {
|
setMessages((prev) => {
|
||||||
for (let i = prev.length - 1; i >= 0; i--) {
|
for (let i = prev.length - 1; i >= 0; i--) {
|
||||||
if (prev[i].role === 'user') {
|
if (prev[i].role === 'user') {
|
||||||
const updated = [...prev]
|
const updated = [...prev];
|
||||||
updated[i] = { ...updated[i], id: userMessageId }
|
updated[i] = { ...updated[i], id: userMessageId };
|
||||||
return updated
|
return updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return prev
|
return prev;
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
|
const handleMessage = useCallback((content: string, attachments?: Attachment[]) => {
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
@ -67,222 +74,233 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
type: 'message',
|
type: 'message',
|
||||||
attachments: attachments || [],
|
attachments: attachments || [],
|
||||||
},
|
},
|
||||||
])
|
]);
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const clearMessages = useCallback(() => {
|
const clearMessages = useCallback(() => {
|
||||||
setMessages([])
|
setMessages([]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleStop = useCallback((): Command => {
|
const handleStop = useCallback((): Command => {
|
||||||
return { type: 'stop_execution' }
|
return { type: 'stop_execution' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleMainViewMessage = useCallback((message: WsOutbound): boolean => {
|
const handleMainViewMessage = useCallback(
|
||||||
switch (message.type) {
|
(message: WsOutbound): boolean => {
|
||||||
case 'task_started': {
|
switch (message.type) {
|
||||||
const msg = message as TaskStarted
|
case 'task_started': {
|
||||||
// 只 backfill 当前话题的 task tool_call,避免跨话题串扰
|
const msg = message as TaskStarted;
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
// 只 backfill 当前话题的 task tool_call,避免跨话题串扰
|
||||||
// 孙智能体的 TaskStarted 不应 backfill 到主视图
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
if (msg.parent_task_id) return true
|
// 孙智能体的 TaskStarted 不应 backfill 到主视图
|
||||||
|
if (msg.parent_task_id) return true;
|
||||||
|
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
// 优先:按 tool_call_id 精确匹配
|
// 优先:按 tool_call_id 精确匹配
|
||||||
if (msg.tool_call_id) {
|
if (msg.tool_call_id) {
|
||||||
const idx = prev.findIndex(m =>
|
const idx = prev.findIndex(
|
||||||
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
(m) =>
|
||||||
if (idx >= 0 && !prev[idx].navigateToTaskId) {
|
m.toolCallId === msg.tool_call_id &&
|
||||||
const updated = [...prev]
|
m.type === 'tool_call' &&
|
||||||
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
|
m.toolName === 'task',
|
||||||
return updated
|
);
|
||||||
|
if (idx >= 0 && !prev[idx].navigateToTaskId) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
||||||
// 回退:backward-search (兼容无 tool_call_id 的旧版本)
|
for (let i = prev.length - 1; i >= 0; i--) {
|
||||||
for (let i = prev.length - 1; i >= 0; i--) {
|
if (
|
||||||
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
|
prev[i].type === 'tool_call' &&
|
||||||
const updated = [...prev]
|
prev[i].toolName === 'task' &&
|
||||||
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
|
!prev[i].navigateToTaskId
|
||||||
return updated
|
) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return prev;
|
||||||
return prev
|
});
|
||||||
})
|
return true;
|
||||||
return true
|
}
|
||||||
}
|
|
||||||
|
|
||||||
case 'stream_delta': {
|
case 'stream_delta': {
|
||||||
const msg = message as StreamDelta
|
const msg = message as StreamDelta;
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
|
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
const updated = [...prev]
|
const updated = [...prev];
|
||||||
const existing = updated[existingIdx]
|
const existing = updated[existingIdx];
|
||||||
updated[existingIdx] = {
|
updated[existingIdx] = {
|
||||||
...existing,
|
...existing,
|
||||||
content: existing.content + msg.delta,
|
content: existing.content + msg.delta,
|
||||||
reasoningContent: msg.reasoning_delta
|
reasoningContent: msg.reasoning_delta
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||||
: existing.reasoningContent,
|
: existing.reasoningContent,
|
||||||
|
};
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
return updated
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: msg.id,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: msg.delta,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
type: 'message' as const,
|
||||||
|
reasoningContent: msg.reasoning_delta,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stream_end': {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'execution_completed': {
|
||||||
|
const msg = message as ExecutionCompleted;
|
||||||
|
if (getSubagentTaskId(message)) return true;
|
||||||
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
|
setIsLoading(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'assistant_response': {
|
||||||
|
const msg = message as AssistantResponse;
|
||||||
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
|
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant';
|
||||||
|
setMessages((prev) => {
|
||||||
|
const existingIdx = prev.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||||||
|
const newMsg: ChatMessage = {
|
||||||
|
id: msg.id,
|
||||||
|
role,
|
||||||
|
content: msg.content,
|
||||||
|
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
|
type: 'message',
|
||||||
|
attachments: msg.attachments,
|
||||||
|
reasoningContent: msg.reasoning_content,
|
||||||
|
};
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existingIdx] = newMsg;
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [...prev, newMsg];
|
||||||
|
});
|
||||||
|
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
|
||||||
|
const currentTopic = topicsRef.current.find((t) => t.id === selectedTopicRef.current);
|
||||||
|
if (currentTopic && !currentTopic.description) {
|
||||||
|
bumpTopicRefreshTrigger();
|
||||||
}
|
}
|
||||||
return [
|
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const msg = message as ToolCall;
|
||||||
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'assistant' as const,
|
role: 'tool',
|
||||||
content: msg.delta,
|
content: msg.content,
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'message' as const,
|
type: 'tool_call',
|
||||||
reasoningContent: msg.reasoning_delta,
|
toolName: msg.tool_name,
|
||||||
|
toolCallId: msg.tool_call_id,
|
||||||
|
arguments: msg.arguments,
|
||||||
|
subagentTaskId: msg.subagent_task_id,
|
||||||
|
reasoningContent: msg.reasoning_content,
|
||||||
},
|
},
|
||||||
]
|
]);
|
||||||
})
|
if (msg.user_message_id) applyUserMessageId(msg.user_message_id);
|
||||||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
|
return true;
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'stream_end': {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'execution_completed': {
|
|
||||||
const msg = message as ExecutionCompleted
|
|
||||||
if (getSubagentTaskId(message)) return true
|
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
|
||||||
setIsLoading(false)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'assistant_response': {
|
|
||||||
const msg = message as AssistantResponse
|
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
|
||||||
const role = msg.role === 'user' || msg.role === 'tool' ? msg.role : 'assistant'
|
|
||||||
setMessages((prev) => {
|
|
||||||
const existingIdx = prev.findIndex(m => m.id === msg.id && m.type === 'message')
|
|
||||||
const newMsg: ChatMessage = {
|
|
||||||
id: msg.id,
|
|
||||||
role,
|
|
||||||
content: msg.content,
|
|
||||||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
|
||||||
type: 'message',
|
|
||||||
attachments: msg.attachments,
|
|
||||||
reasoningContent: msg.reasoning_content,
|
|
||||||
}
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
const updated = [...prev]
|
|
||||||
updated[existingIdx] = newMsg
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
return [...prev, newMsg]
|
|
||||||
})
|
|
||||||
// 当前话题无描述时,可能刚触发了异步生成,标记需要刷新
|
|
||||||
const currentTopic = topicsRef.current.find(t => t.id === selectedTopicRef.current)
|
|
||||||
if (currentTopic && !currentTopic.description) {
|
|
||||||
bumpTopicRefreshTrigger()
|
|
||||||
}
|
}
|
||||||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_call': {
|
case 'tool_result': {
|
||||||
const msg = message as ToolCall
|
const msg = message as ToolResult;
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: msg.content,
|
content: msg.content,
|
||||||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'tool_call',
|
type: 'tool_result',
|
||||||
toolName: msg.tool_name,
|
toolName: msg.tool_name,
|
||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
arguments: msg.arguments,
|
subagentTaskId: msg.subagent_task_id,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
durationMs: msg.duration_ms,
|
||||||
reasoningContent: msg.reasoning_content,
|
},
|
||||||
},
|
]);
|
||||||
])
|
return true;
|
||||||
if (msg.user_message_id) applyUserMessageId(msg.user_message_id)
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_result': {
|
case 'tool_pending': {
|
||||||
const msg = message as ToolResult
|
const msg = message as ToolPending;
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: msg.content,
|
content: `${msg.content}\n\n${msg.resume_hint}`,
|
||||||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'tool_result',
|
type: 'tool_pending',
|
||||||
toolName: msg.tool_name,
|
toolName: msg.tool_name,
|
||||||
toolCallId: msg.tool_call_id,
|
toolCallId: msg.tool_call_id,
|
||||||
subagentTaskId: msg.subagent_task_id,
|
},
|
||||||
durationMs: msg.duration_ms,
|
]);
|
||||||
},
|
return true;
|
||||||
])
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'tool_pending': {
|
case 'execution_cancelled': {
|
||||||
const msg = message as ToolPending
|
setMessages((prev) => [
|
||||||
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) return true
|
...prev,
|
||||||
setMessages((prev) => [
|
{
|
||||||
...prev,
|
id: generateMessageId(),
|
||||||
{
|
role: 'assistant',
|
||||||
id: msg.id,
|
content: (message as { type: 'execution_cancelled'; message: string }).message,
|
||||||
role: 'tool',
|
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
content: `${msg.content}\n\n${msg.resume_hint}`,
|
type: 'message',
|
||||||
timestamp: msg.timestamp ?? Math.floor(Date.now() / 1000),
|
},
|
||||||
type: 'tool_pending',
|
]);
|
||||||
toolName: msg.tool_name,
|
setIsLoading(false);
|
||||||
toolCallId: msg.tool_call_id,
|
return true;
|
||||||
},
|
}
|
||||||
])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'execution_cancelled': {
|
case 'error': {
|
||||||
setMessages((prev) => [
|
if (getSubagentTaskId(message)) return true;
|
||||||
...prev,
|
setMessages((prev) => [
|
||||||
{
|
...prev,
|
||||||
id: generateMessageId(),
|
{
|
||||||
role: 'assistant',
|
id: generateMessageId(),
|
||||||
content: (message as { type: 'execution_cancelled'; message: string }).message,
|
role: 'assistant',
|
||||||
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
content: `Error: ${(message as WsError).message}`,
|
||||||
type: 'message',
|
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
},
|
type: 'message',
|
||||||
])
|
},
|
||||||
setIsLoading(false)
|
]);
|
||||||
return true
|
setIsLoading(false);
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case 'error': {
|
default:
|
||||||
if (getSubagentTaskId(message)) return true
|
return false;
|
||||||
setMessages((prev) => [
|
|
||||||
...prev,
|
|
||||||
{
|
|
||||||
id: generateMessageId(),
|
|
||||||
role: 'assistant',
|
|
||||||
content: `Error: ${(message as WsError).message}`,
|
|
||||||
timestamp: message.timestamp ?? Math.floor(Date.now() / 1000),
|
|
||||||
type: 'message',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setIsLoading(false)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
default:
|
[selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId],
|
||||||
return false
|
);
|
||||||
}
|
|
||||||
}, [selectedTopicRef, topicsRef, bumpTopicRefreshTrigger, applyUserMessageId])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
@ -293,5 +311,5 @@ export function useMessages(options: UseMessagesOptions): UseMessagesReturn {
|
|||||||
clearMessages,
|
clearMessages,
|
||||||
handleStop,
|
handleStop,
|
||||||
handleMainViewMessage,
|
handleMainViewMessage,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +1,54 @@
|
|||||||
import { useState, useCallback, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
type MutableRefObject,
|
||||||
|
} from 'react';
|
||||||
import type {
|
import type {
|
||||||
WsOutbound,
|
WsOutbound,
|
||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
Command,
|
Command,
|
||||||
} from '../../types/protocol'
|
} from '../../types/protocol';
|
||||||
import { serverMessageToChatMessage } from './messageMappers'
|
import { serverMessageToChatMessage } from './messageMappers';
|
||||||
import type { SchedulerJobView } from './types'
|
import type { SchedulerJobView } from './types';
|
||||||
|
|
||||||
export interface UseSchedulerViewReturn {
|
export interface UseSchedulerViewReturn {
|
||||||
schedulerView: SchedulerJobView | null
|
schedulerView: SchedulerJobView | null;
|
||||||
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>
|
setSchedulerView: Dispatch<SetStateAction<SchedulerJobView | null>>;
|
||||||
schedulerViewRef: MutableRefObject<SchedulerJobView | null>
|
schedulerViewRef: MutableRefObject<SchedulerJobView | null>;
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[];
|
||||||
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>
|
setSchedulerJobs: Dispatch<SetStateAction<SchedulerJobSummary[]>>;
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler';
|
||||||
setSidebarTab: (tab: 'topics' | 'scheduler') => void
|
setSidebarTab: (tab: 'topics' | 'scheduler') => void;
|
||||||
requestSchedulerJobList: () => Command
|
requestSchedulerJobList: () => Command;
|
||||||
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
|
enterSchedulerJobView: (
|
||||||
exitSchedulerJobView: () => void
|
lookup: SchedulerJobSessionLookup,
|
||||||
|
jobId: string,
|
||||||
|
description: string,
|
||||||
|
) => Command;
|
||||||
|
exitSchedulerJobView: () => void;
|
||||||
/** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */
|
/** Tier 1 路由:调度器视图激活时处理消息,返回是否已处理 */
|
||||||
handleSchedulerMessage: (message: WsOutbound) => boolean
|
handleSchedulerMessage: (message: WsOutbound) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSchedulerView(): UseSchedulerViewReturn {
|
export function useSchedulerView(): UseSchedulerViewReturn {
|
||||||
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
|
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null);
|
||||||
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
|
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]);
|
||||||
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
|
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics');
|
||||||
|
|
||||||
const schedulerViewRef = useRef<SchedulerJobView | null>(null)
|
const schedulerViewRef = useRef<SchedulerJobView | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
schedulerViewRef.current = schedulerView
|
schedulerViewRef.current = schedulerView;
|
||||||
}, [schedulerView])
|
}, [schedulerView]);
|
||||||
|
|
||||||
const requestSchedulerJobList = useCallback((): Command => {
|
const requestSchedulerJobList = useCallback((): Command => {
|
||||||
return { type: 'list_scheduler_jobs' }
|
return { type: 'list_scheduler_jobs' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const enterSchedulerJobView = useCallback(
|
const enterSchedulerJobView = useCallback(
|
||||||
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
|
(lookup: SchedulerJobSessionLookup, jobId: string, description: string): Command => {
|
||||||
@ -46,40 +58,38 @@ export function useSchedulerView(): UseSchedulerViewReturn {
|
|||||||
channel: lookup.channel,
|
channel: lookup.channel,
|
||||||
chatId: lookup.chat_id,
|
chatId: lookup.chat_id,
|
||||||
messages: [],
|
messages: [],
|
||||||
}
|
};
|
||||||
schedulerViewRef.current = newView
|
schedulerViewRef.current = newView;
|
||||||
setSchedulerView(newView)
|
setSchedulerView(newView);
|
||||||
return {
|
return {
|
||||||
type: 'load_chat_messages',
|
type: 'load_chat_messages',
|
||||||
channel: lookup.channel,
|
channel: lookup.channel,
|
||||||
chat_id: lookup.chat_id,
|
chat_id: lookup.chat_id,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
[]
|
[],
|
||||||
)
|
);
|
||||||
|
|
||||||
const exitSchedulerJobView = useCallback(() => {
|
const exitSchedulerJobView = useCallback(() => {
|
||||||
schedulerViewRef.current = null
|
schedulerViewRef.current = null;
|
||||||
setSchedulerView(null)
|
setSchedulerView(null);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
/** Tier 1 路由:调度器视图激活时,chat 消息追加到 schedulerView;非 chat 消息 fall through */
|
/** Tier 1 路由:调度器视图激活时,chat 消息追加到 schedulerView;非 chat 消息 fall through */
|
||||||
const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => {
|
const handleSchedulerMessage = useCallback((message: WsOutbound): boolean => {
|
||||||
const currentSchedulerView = schedulerViewRef.current
|
const currentSchedulerView = schedulerViewRef.current;
|
||||||
if (!currentSchedulerView) return false
|
if (!currentSchedulerView) return false;
|
||||||
|
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message);
|
||||||
if (chatMsg) {
|
if (chatMsg) {
|
||||||
setSchedulerView((prev) =>
|
setSchedulerView((prev) =>
|
||||||
prev
|
prev ? { ...prev, messages: [...prev.messages, chatMsg] } : prev,
|
||||||
? { ...prev, messages: [...prev.messages, chatMsg] }
|
);
|
||||||
: prev
|
return true;
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
|
// Non-chat messages (session_list, topic_list, etc.) fall through to main handler
|
||||||
return false
|
return false;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置
|
// scheduler_job_list 在主视图 switch 中处理,通过 setSchedulerJobs 设置
|
||||||
return {
|
return {
|
||||||
@ -94,5 +104,5 @@ export function useSchedulerView(): UseSchedulerViewReturn {
|
|||||||
enterSchedulerJobView,
|
enterSchedulerJobView,
|
||||||
exitSchedulerJobView,
|
exitSchedulerJobView,
|
||||||
handleSchedulerMessage,
|
handleSchedulerMessage,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +1,49 @@
|
|||||||
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
|
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||||
import type { SessionSummary, Command } from '../../types/protocol'
|
import type { SessionSummary, Command } from '../../types/protocol';
|
||||||
|
|
||||||
export interface UseSessionsReturn {
|
export interface UseSessionsReturn {
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[];
|
||||||
setSessions: Dispatch<SetStateAction<SessionSummary[]>>
|
setSessions: Dispatch<SetStateAction<SessionSummary[]>>;
|
||||||
selectedSessionId: string | null
|
selectedSessionId: string | null;
|
||||||
setSelectedSessionId: Dispatch<SetStateAction<string | null>>
|
setSelectedSessionId: Dispatch<SetStateAction<string | null>>;
|
||||||
session: SessionSummary | null
|
session: SessionSummary | null;
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
chatId: string
|
chatId: string;
|
||||||
selectSession: (sessionId: string) => void
|
selectSession: (sessionId: string) => void;
|
||||||
requestSessionList: (selectedChannel: string) => Command
|
requestSessionList: (selectedChannel: string) => Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseSessionsOptions {
|
interface UseSessionsOptions {
|
||||||
/** selectSession 时额外执行的副作用 */
|
/** selectSession 时额外执行的副作用 */
|
||||||
onSessionChange?: () => void
|
onSessionChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
|
export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
|
||||||
const [sessions, setSessions] = useState<SessionSummary[]>([])
|
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedSession = useMemo(
|
const selectedSession = useMemo(
|
||||||
() => sessions.find(s => s.session_id === selectedSessionId) ?? null,
|
() => sessions.find((s) => s.session_id === selectedSessionId) ?? null,
|
||||||
[sessions, selectedSessionId]
|
[sessions, selectedSessionId],
|
||||||
)
|
);
|
||||||
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession])
|
const sessionId = useMemo(() => selectedSession?.session_id ?? null, [selectedSession]);
|
||||||
const chatId = useMemo(() => sessionId ?? 'default', [sessionId])
|
const chatId = useMemo(() => sessionId ?? 'default', [sessionId]);
|
||||||
|
|
||||||
const selectSession = useCallback((id: string) => {
|
const selectSession = useCallback(
|
||||||
setSelectedSessionId(id)
|
(id: string) => {
|
||||||
options?.onSessionChange?.()
|
setSelectedSessionId(id);
|
||||||
}, [options])
|
options?.onSessionChange?.();
|
||||||
|
},
|
||||||
|
[options],
|
||||||
|
);
|
||||||
|
|
||||||
const requestSessionList = useCallback((selectedChannel: string): Command => {
|
const requestSessionList = useCallback((selectedChannel: string): Command => {
|
||||||
return {
|
return {
|
||||||
type: 'list_sessions_by_channel',
|
type: 'list_sessions_by_channel',
|
||||||
channel_name: selectedChannel,
|
channel_name: selectedChannel,
|
||||||
include_archived: false,
|
include_archived: false,
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
@ -52,5 +55,5 @@ export function useSessions(options?: UseSessionsOptions): UseSessionsReturn {
|
|||||||
chatId,
|
chatId,
|
||||||
selectSession,
|
selectSession,
|
||||||
requestSessionList,
|
requestSessionList,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,82 +1,82 @@
|
|||||||
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
|
import { useState, useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||||
import type {
|
import type {
|
||||||
MemorySummary,
|
MemorySummary,
|
||||||
SkillSummary,
|
SkillSummary,
|
||||||
TodoItemSummary,
|
TodoItemSummary,
|
||||||
Channel,
|
Channel,
|
||||||
Command,
|
Command,
|
||||||
} from '../../types/protocol'
|
} from '../../types/protocol';
|
||||||
|
|
||||||
export interface UseSideDataReturn {
|
export interface UseSideDataReturn {
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[];
|
||||||
setMemories: Dispatch<SetStateAction<MemorySummary[]>>
|
setMemories: Dispatch<SetStateAction<MemorySummary[]>>;
|
||||||
skills: SkillSummary[]
|
skills: SkillSummary[];
|
||||||
setSkills: Dispatch<SetStateAction<SkillSummary[]>>
|
setSkills: Dispatch<SetStateAction<SkillSummary[]>>;
|
||||||
todos: TodoItemSummary[]
|
todos: TodoItemSummary[];
|
||||||
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
|
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
|
||||||
highlightedMessageId: string | null
|
highlightedMessageId: string | null;
|
||||||
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
|
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
|
||||||
channels: Channel[]
|
channels: Channel[];
|
||||||
setChannels: Dispatch<SetStateAction<Channel[]>>
|
setChannels: Dispatch<SetStateAction<Channel[]>>;
|
||||||
selectedChannel: string
|
selectedChannel: string;
|
||||||
setSelectedChannel: Dispatch<SetStateAction<string>>
|
setSelectedChannel: Dispatch<SetStateAction<string>>;
|
||||||
isWritable: boolean
|
isWritable: boolean;
|
||||||
|
|
||||||
requestMemoryList: () => Command
|
requestMemoryList: () => Command;
|
||||||
createMemory: (namespace: string, key: string, content: string) => Command
|
createMemory: (namespace: string, key: string, content: string) => Command;
|
||||||
updateMemory: (id: string, content: string) => Command
|
updateMemory: (id: string, content: string) => Command;
|
||||||
deleteMemory: (id: string) => Command
|
deleteMemory: (id: string) => Command;
|
||||||
requestSkillList: () => Command
|
requestSkillList: () => Command;
|
||||||
requestTodoList: () => Command
|
requestTodoList: () => Command;
|
||||||
requestSubAgentTodoList: (subTaskId: string) => Command
|
requestSubAgentTodoList: (subTaskId: string) => Command;
|
||||||
requestChannelList: () => Command
|
requestChannelList: () => Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSideData(): UseSideDataReturn {
|
export function useSideData(): UseSideDataReturn {
|
||||||
const [memories, setMemories] = useState<MemorySummary[]>([])
|
const [memories, setMemories] = useState<MemorySummary[]>([]);
|
||||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||||
const [todos, setTodos] = useState<TodoItemSummary[]>([])
|
const [todos, setTodos] = useState<TodoItemSummary[]>([]);
|
||||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
|
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null);
|
||||||
const [channels, setChannels] = useState<Channel[]>([])
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
const [selectedChannel, setSelectedChannel] = useState<string>('websocket')
|
const [selectedChannel, setSelectedChannel] = useState<string>('websocket');
|
||||||
|
|
||||||
const isWritable = useMemo(
|
const isWritable = useMemo(
|
||||||
() => channels.find(c => c.id === selectedChannel)?.isWritable ?? false,
|
() => channels.find((c) => c.id === selectedChannel)?.isWritable ?? false,
|
||||||
[channels, selectedChannel]
|
[channels, selectedChannel],
|
||||||
)
|
);
|
||||||
|
|
||||||
const requestMemoryList = useCallback((): Command => {
|
const requestMemoryList = useCallback((): Command => {
|
||||||
return { type: 'list_memories' }
|
return { type: 'list_memories' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
|
const createMemory = useCallback((namespace: string, key: string, content: string): Command => {
|
||||||
return { type: 'create_memory', namespace, key, content }
|
return { type: 'create_memory', namespace, key, content };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const updateMemory = useCallback((id: string, content: string): Command => {
|
const updateMemory = useCallback((id: string, content: string): Command => {
|
||||||
return { type: 'update_memory', id, content }
|
return { type: 'update_memory', id, content };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const deleteMemory = useCallback((id: string): Command => {
|
const deleteMemory = useCallback((id: string): Command => {
|
||||||
return { type: 'delete_memory', id }
|
return { type: 'delete_memory', id };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const requestSkillList = useCallback((): Command => {
|
const requestSkillList = useCallback((): Command => {
|
||||||
return { type: 'list_skills' }
|
return { type: 'list_skills' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const requestTodoList = useCallback((): Command => {
|
const requestTodoList = useCallback((): Command => {
|
||||||
return { type: 'list_todos' }
|
return { type: 'list_todos' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
|
const requestSubAgentTodoList = useCallback((subTaskId: string): Command => {
|
||||||
return { type: 'list_todos', task_id: subTaskId }
|
return { type: 'list_todos', task_id: subTaskId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const requestChannelList = useCallback((): Command => {
|
const requestChannelList = useCallback((): Command => {
|
||||||
return { type: 'list_channels' }
|
return { type: 'list_channels' };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
memories,
|
memories,
|
||||||
@ -100,5 +100,5 @@ export function useSideData(): UseSideDataReturn {
|
|||||||
requestTodoList,
|
requestTodoList,
|
||||||
requestSubAgentTodoList,
|
requestSubAgentTodoList,
|
||||||
requestChannelList,
|
requestChannelList,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,13 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
import {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
type MutableRefObject,
|
||||||
|
} from 'react';
|
||||||
import type {
|
import type {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
WsOutbound,
|
WsOutbound,
|
||||||
@ -8,370 +17,413 @@ import type {
|
|||||||
TaskStarted,
|
TaskStarted,
|
||||||
TaskMessagesLoaded,
|
TaskMessagesLoaded,
|
||||||
Command,
|
Command,
|
||||||
} from '../../types/protocol'
|
} from '../../types/protocol';
|
||||||
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers'
|
import { generateMessageId, getSubagentTaskId, serverMessageToChatMessage } from './messageMappers';
|
||||||
import type { SubAgentView } from './types'
|
import type { SubAgentView } from './types';
|
||||||
|
|
||||||
interface UseSubAgentViewOptions {
|
interface UseSubAgentViewOptions {
|
||||||
/** 发送命令到后端(用于子代理 todo_write 后刷新待办) */
|
/** 发送命令到后端(用于子代理 todo_write 后刷新待办) */
|
||||||
sendCommand: (cmd: Command) => void
|
sendCommand: (cmd: Command) => void;
|
||||||
/** 构建子代理待办刷新命令 */
|
/** 构建子代理待办刷新命令 */
|
||||||
requestSubAgentTodoList: (subTaskId: string) => Command
|
requestSubAgentTodoList: (subTaskId: string) => Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseSubAgentViewReturn {
|
export interface UseSubAgentViewReturn {
|
||||||
subAgentStack: SubAgentView[]
|
subAgentStack: SubAgentView[];
|
||||||
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>
|
setSubAgentStack: Dispatch<SetStateAction<SubAgentView[]>>;
|
||||||
subAgentView: SubAgentView | null
|
subAgentView: SubAgentView | null;
|
||||||
subAgentViewRef: MutableRefObject<SubAgentView | null>
|
subAgentViewRef: MutableRefObject<SubAgentView | null>;
|
||||||
subAgentStackRef: MutableRefObject<SubAgentView[]>
|
subAgentStackRef: MutableRefObject<SubAgentView[]>;
|
||||||
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
|
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
|
||||||
exitSubAgentView: () => Command | null
|
exitSubAgentView: () => Command | null;
|
||||||
navigateToSubAgentLevel: (index: number) => Command | null
|
navigateToSubAgentLevel: (index: number) => Command | null;
|
||||||
/** 处理子智能体视图的消息路由(Tier 2),返回是否已处理 */
|
/** 处理子智能体视图的消息路由(Tier 2),返回是否已处理 */
|
||||||
handleSubAgentMessage: (message: WsOutbound) => boolean
|
handleSubAgentMessage: (message: WsOutbound) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn {
|
export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentViewReturn {
|
||||||
const { sendCommand, requestSubAgentTodoList } = options
|
const { sendCommand, requestSubAgentTodoList } = options;
|
||||||
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([])
|
const [subAgentStack, setSubAgentStack] = useState<SubAgentView[]>([]);
|
||||||
const subAgentView = useMemo(() => subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null, [subAgentStack])
|
const subAgentView = useMemo(
|
||||||
|
() => (subAgentStack.length > 0 ? subAgentStack[subAgentStack.length - 1] : null),
|
||||||
|
[subAgentStack],
|
||||||
|
);
|
||||||
|
|
||||||
const subAgentViewRef = useRef<SubAgentView | null>(null)
|
const subAgentViewRef = useRef<SubAgentView | null>(null);
|
||||||
const subAgentStackRef = useRef<SubAgentView[]>([])
|
const subAgentStackRef = useRef<SubAgentView[]>([]);
|
||||||
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
|
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// ref 同步:确保回调中读到最新值
|
// ref 同步:确保回调中读到最新值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subAgentViewRef.current = subAgentView
|
subAgentViewRef.current = subAgentView;
|
||||||
}, [subAgentView])
|
}, [subAgentView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subAgentStackRef.current = subAgentStack
|
subAgentStackRef.current = subAgentStack;
|
||||||
}, [subAgentStack])
|
}, [subAgentStack]);
|
||||||
|
|
||||||
// 追加消息到栈顶视图(含流式累加)
|
// 追加消息到栈顶视图(含流式累加)
|
||||||
const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => {
|
const appendToSubAgentViewMessage = useCallback((message: WsOutbound) => {
|
||||||
// stream_delta: accumulate into existing message by ID, or create new
|
// stream_delta: accumulate into existing message by ID, or create new
|
||||||
if (message.type === 'stream_delta') {
|
if (message.type === 'stream_delta') {
|
||||||
const msg = message as StreamDelta
|
const msg = message as StreamDelta;
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev;
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1];
|
||||||
const existingIdx = top.messages.findIndex(m => m.id === msg.id && m.type === 'message')
|
const existingIdx = top.messages.findIndex((m) => m.id === msg.id && m.type === 'message');
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
const updated = [...top.messages]
|
const updated = [...top.messages];
|
||||||
const existing = updated[existingIdx]
|
const existing = updated[existingIdx];
|
||||||
updated[existingIdx] = {
|
updated[existingIdx] = {
|
||||||
...existing,
|
...existing,
|
||||||
content: existing.content + msg.delta,
|
content: existing.content + msg.delta,
|
||||||
reasoningContent: msg.reasoning_delta
|
reasoningContent: msg.reasoning_delta
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||||
: existing.reasoningContent,
|
: existing.reasoningContent,
|
||||||
}
|
};
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updated }
|
newStack[newStack.length - 1] = { ...top, messages: updated };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message);
|
||||||
if (!chatMsg) return prev
|
if (!chatMsg) return prev;
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
|
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
|
||||||
return newStack
|
return newStack;
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// stream_end: no-op, assistant_response will replace
|
// stream_end: no-op, assistant_response will replace
|
||||||
if (message.type === 'stream_end') return
|
if (message.type === 'stream_end') return;
|
||||||
// execution_completed: 更新栈顶 status 为 completed
|
// execution_completed: 更新栈顶 status 为 completed
|
||||||
if (message.type === 'execution_completed') {
|
if (message.type === 'execution_completed') {
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev;
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1];
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, status: 'completed' }
|
newStack[newStack.length - 1] = { ...top, status: 'completed' };
|
||||||
return newStack
|
return newStack;
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
// error: 更新栈顶 status 为 error,并追加错误消息
|
// error: 更新栈顶 status 为 error,并追加错误消息
|
||||||
if (message.type === 'error') {
|
if (message.type === 'error') {
|
||||||
const errMsg = message as WsError
|
const errMsg = message as WsError;
|
||||||
const errorChatMsg: ChatMessage = {
|
const errorChatMsg: ChatMessage = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Error: ${errMsg.message}`,
|
content: `Error: ${errMsg.message}`,
|
||||||
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
|
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
}
|
};
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev;
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1];
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, status: 'error', messages: [...top.messages, errorChatMsg] }
|
newStack[newStack.length - 1] = {
|
||||||
return newStack
|
...top,
|
||||||
})
|
status: 'error',
|
||||||
return
|
messages: [...top.messages, errorChatMsg],
|
||||||
|
};
|
||||||
|
return newStack;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
// Other messages: assistant_response replaces streamed message by ID
|
// Other messages: assistant_response replaces streamed message by ID
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message);
|
||||||
if (chatMsg) {
|
if (chatMsg) {
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev;
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1];
|
||||||
if (message.type === 'assistant_response') {
|
if (message.type === 'assistant_response') {
|
||||||
const existingIdx = top.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
|
const existingIdx = top.messages.findIndex(
|
||||||
|
(m) => m.id === chatMsg.id && m.type === 'message',
|
||||||
|
);
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
const updated = [...top.messages]
|
const updated = [...top.messages];
|
||||||
updated[existingIdx] = chatMsg
|
updated[existingIdx] = chatMsg;
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updated }
|
newStack[newStack.length - 1] = { ...top, messages: updated };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') {
|
} else if (
|
||||||
|
message.type === 'tool_call' ||
|
||||||
|
message.type === 'tool_result' ||
|
||||||
|
message.type === 'tool_pending'
|
||||||
|
) {
|
||||||
// 按 id + type 去重,避免 load_task_messages 并发调用导致重复。
|
// 按 id + type 去重,避免 load_task_messages 并发调用导致重复。
|
||||||
const exists = top.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type)
|
const exists = top.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
|
||||||
if (exists) return prev
|
if (exists) return prev;
|
||||||
}
|
}
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] }
|
newStack[newStack.length - 1] = { ...top, messages: [...top.messages, chatMsg] };
|
||||||
return newStack
|
return newStack;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
// 追加消息到栈中非栈顶的匹配层(按 taskId 匹配)
|
||||||
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
|
const appendToSubAgentLayerMessage = useCallback((taskId: string, message: WsOutbound) => {
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
const idx = prev.findIndex(v => v.taskId === taskId)
|
const idx = prev.findIndex((v) => v.taskId === taskId);
|
||||||
if (idx < 0) return prev
|
if (idx < 0) return prev;
|
||||||
const layer = prev[idx]
|
const layer = prev[idx];
|
||||||
|
|
||||||
if (message.type === 'execution_completed') {
|
if (message.type === 'execution_completed') {
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, status: 'completed' }
|
newStack[idx] = { ...layer, status: 'completed' };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
if (message.type === 'error') {
|
if (message.type === 'error') {
|
||||||
const errMsg = message as WsError
|
const errMsg = message as WsError;
|
||||||
const errorChatMsg: ChatMessage = {
|
const errorChatMsg: ChatMessage = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `Error: ${errMsg.message}`,
|
content: `Error: ${errMsg.message}`,
|
||||||
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
|
timestamp: errMsg.timestamp ?? Math.floor(Date.now() / 1000),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
}
|
};
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] }
|
newStack[idx] = { ...layer, status: 'error', messages: [...layer.messages, errorChatMsg] };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
if (message.type === 'stream_delta') {
|
if (message.type === 'stream_delta') {
|
||||||
const msg = message as StreamDelta
|
const msg = message as StreamDelta;
|
||||||
const existingIdx = layer.messages.findIndex(m => m.id === msg.id && m.type === 'message')
|
const existingIdx = layer.messages.findIndex(
|
||||||
|
(m) => m.id === msg.id && m.type === 'message',
|
||||||
|
);
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
const updated = [...layer.messages]
|
const updated = [...layer.messages];
|
||||||
const existing = updated[existingIdx]
|
const existing = updated[existingIdx];
|
||||||
updated[existingIdx] = {
|
updated[existingIdx] = {
|
||||||
...existing,
|
...existing,
|
||||||
content: existing.content + msg.delta,
|
content: existing.content + msg.delta,
|
||||||
reasoningContent: msg.reasoning_delta
|
reasoningContent: msg.reasoning_delta
|
||||||
? (existing.reasoningContent || '') + msg.reasoning_delta
|
? (existing.reasoningContent || '') + msg.reasoning_delta
|
||||||
: existing.reasoningContent,
|
: existing.reasoningContent,
|
||||||
}
|
};
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, messages: updated }
|
newStack[idx] = { ...layer, messages: updated };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message);
|
||||||
if (!chatMsg) return prev
|
if (!chatMsg) return prev;
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
|
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
if (message.type === 'stream_end') return prev
|
if (message.type === 'stream_end') return prev;
|
||||||
const chatMsg = serverMessageToChatMessage(message)
|
const chatMsg = serverMessageToChatMessage(message);
|
||||||
if (!chatMsg) return prev
|
if (!chatMsg) return prev;
|
||||||
if (message.type === 'assistant_response') {
|
if (message.type === 'assistant_response') {
|
||||||
const existingIdx = layer.messages.findIndex(m => m.id === chatMsg.id && m.type === 'message')
|
const existingIdx = layer.messages.findIndex(
|
||||||
|
(m) => m.id === chatMsg.id && m.type === 'message',
|
||||||
|
);
|
||||||
if (existingIdx >= 0) {
|
if (existingIdx >= 0) {
|
||||||
const updated = [...layer.messages]
|
const updated = [...layer.messages];
|
||||||
updated[existingIdx] = chatMsg
|
updated[existingIdx] = chatMsg;
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, messages: updated }
|
newStack[idx] = { ...layer, messages: updated };
|
||||||
return newStack
|
return newStack;
|
||||||
}
|
}
|
||||||
} else if (message.type === 'tool_call' || message.type === 'tool_result' || message.type === 'tool_pending') {
|
} else if (
|
||||||
const exists = layer.messages.some(m => m.id === chatMsg.id && m.type === chatMsg.type)
|
message.type === 'tool_call' ||
|
||||||
if (exists) return prev
|
message.type === 'tool_result' ||
|
||||||
|
message.type === 'tool_pending'
|
||||||
|
) {
|
||||||
|
const exists = layer.messages.some((m) => m.id === chatMsg.id && m.type === chatMsg.type);
|
||||||
|
if (exists) return prev;
|
||||||
}
|
}
|
||||||
const newStack = [...prev]
|
const newStack = [...prev];
|
||||||
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] }
|
newStack[idx] = { ...layer, messages: [...layer.messages, chatMsg] };
|
||||||
return newStack
|
return newStack;
|
||||||
})
|
});
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const enterSubAgentView = useCallback((taskId: string, description: string, subagentType?: string): Command => {
|
const enterSubAgentView = useCallback(
|
||||||
const newView: SubAgentView = {
|
(taskId: string, description: string, subagentType?: string): Command => {
|
||||||
taskId,
|
const newView: SubAgentView = {
|
||||||
description,
|
taskId,
|
||||||
subagentType: subagentType || '',
|
description,
|
||||||
status: 'loading',
|
subagentType: subagentType || '',
|
||||||
messages: [],
|
status: 'loading',
|
||||||
}
|
messages: [],
|
||||||
// 同步设置 ref,消除竞态窗口
|
};
|
||||||
subAgentViewRef.current = newView
|
// 同步设置 ref,消除竞态窗口
|
||||||
subAgentStackRef.current = [...subAgentStackRef.current, newView]
|
subAgentViewRef.current = newView;
|
||||||
setSubAgentStack((prev) => [...prev, newView])
|
subAgentStackRef.current = [...subAgentStackRef.current, newView];
|
||||||
return { type: 'load_task_messages', task_id: taskId }
|
setSubAgentStack((prev) => [...prev, newView]);
|
||||||
}, [])
|
return { type: 'load_task_messages', task_id: taskId };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const exitSubAgentView = useCallback((): Command | null => {
|
const exitSubAgentView = useCallback((): Command | null => {
|
||||||
const current = subAgentStackRef.current
|
const current = subAgentStackRef.current;
|
||||||
if (current.length <= 1) {
|
if (current.length <= 1) {
|
||||||
subAgentViewRef.current = null
|
subAgentViewRef.current = null;
|
||||||
subAgentStackRef.current = []
|
subAgentStackRef.current = [];
|
||||||
setSubAgentStack([])
|
setSubAgentStack([]);
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
const newStack = current.slice(0, -1)
|
const newStack = current.slice(0, -1);
|
||||||
const newTop = newStack[newStack.length - 1]
|
const newTop = newStack[newStack.length - 1];
|
||||||
subAgentViewRef.current = newTop
|
subAgentViewRef.current = newTop;
|
||||||
const clearedStack = [...newStack]
|
const clearedStack = [...newStack];
|
||||||
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
|
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
|
||||||
subAgentStackRef.current = clearedStack
|
subAgentStackRef.current = clearedStack;
|
||||||
setSubAgentStack(clearedStack)
|
setSubAgentStack(clearedStack);
|
||||||
return { type: 'load_task_messages', task_id: newTop.taskId }
|
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
|
const navigateToSubAgentLevel = useCallback((index: number): Command | null => {
|
||||||
const current = subAgentStackRef.current
|
const current = subAgentStackRef.current;
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
subAgentViewRef.current = null
|
subAgentViewRef.current = null;
|
||||||
subAgentStackRef.current = []
|
subAgentStackRef.current = [];
|
||||||
setSubAgentStack([])
|
setSubAgentStack([]);
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
if (index >= current.length) return null
|
if (index >= current.length) return null;
|
||||||
const newStack = current.slice(0, index + 1)
|
const newStack = current.slice(0, index + 1);
|
||||||
const newTop = newStack[newStack.length - 1]
|
const newTop = newStack[newStack.length - 1];
|
||||||
subAgentViewRef.current = newTop
|
subAgentViewRef.current = newTop;
|
||||||
const clearedStack = [...newStack]
|
const clearedStack = [...newStack];
|
||||||
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' }
|
clearedStack[clearedStack.length - 1] = { ...newTop, messages: [], status: 'loading' };
|
||||||
subAgentStackRef.current = clearedStack
|
subAgentStackRef.current = clearedStack;
|
||||||
setSubAgentStack(clearedStack)
|
setSubAgentStack(clearedStack);
|
||||||
return { type: 'load_task_messages', task_id: newTop.taskId }
|
return { type: 'load_task_messages', task_id: newTop.taskId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
/** Tier 2 路由:子智能体视图激活时处理消息,返回是否已处理 */
|
||||||
const handleSubAgentMessage = useCallback((message: WsOutbound): boolean => {
|
const handleSubAgentMessage = useCallback(
|
||||||
const currentSubAgentView = subAgentViewRef.current
|
(message: WsOutbound): boolean => {
|
||||||
if (!currentSubAgentView) return false
|
const currentSubAgentView = subAgentViewRef.current;
|
||||||
|
if (!currentSubAgentView) return false;
|
||||||
|
|
||||||
if (message.type === 'task_messages_loaded') {
|
if (message.type === 'task_messages_loaded') {
|
||||||
const msg = message as TaskMessagesLoaded
|
const msg = message as TaskMessagesLoaded;
|
||||||
setSubAgentStack((prev) => {
|
|
||||||
if (prev.length === 0) return prev
|
|
||||||
const top = prev[prev.length - 1]
|
|
||||||
if (msg.task_id !== top.taskId) return prev
|
|
||||||
const newStack = [...prev]
|
|
||||||
newStack[newStack.length - 1] = {
|
|
||||||
...top,
|
|
||||||
subagentType: msg.subagent_type,
|
|
||||||
status: msg.status,
|
|
||||||
summary: msg.summary,
|
|
||||||
}
|
|
||||||
return newStack
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === 'task_started') {
|
|
||||||
const msg = message as TaskStarted
|
|
||||||
if (msg.parent_task_id === currentSubAgentView.taskId) {
|
|
||||||
let matched = false
|
|
||||||
setSubAgentStack((prev) => {
|
setSubAgentStack((prev) => {
|
||||||
if (prev.length === 0) return prev
|
if (prev.length === 0) return prev;
|
||||||
const top = prev[prev.length - 1]
|
const top = prev[prev.length - 1];
|
||||||
const updatedMessages = [...top.messages]
|
if (msg.task_id !== top.taskId) return prev;
|
||||||
|
const newStack = [...prev];
|
||||||
if (msg.tool_call_id) {
|
newStack[newStack.length - 1] = {
|
||||||
const idx = updatedMessages.findIndex(m =>
|
...top,
|
||||||
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
|
subagentType: msg.subagent_type,
|
||||||
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
|
status: msg.status,
|
||||||
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
|
summary: msg.summary,
|
||||||
matched = true
|
};
|
||||||
const newStack = [...prev]
|
return newStack;
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
});
|
||||||
return newStack
|
return true;
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
|
||||||
const m = updatedMessages[i]
|
|
||||||
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
|
|
||||||
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newStack = [...prev]
|
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
|
||||||
return newStack
|
|
||||||
})
|
|
||||||
if (!matched) {
|
|
||||||
const key = msg.tool_call_id || `fallback:${msg.task_id}`
|
|
||||||
pendingTaskNavsRef.current.set(key, msg.task_id)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const msgSubagentTaskId = getSubagentTaskId(message)
|
if (message.type === 'task_started') {
|
||||||
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
|
const msg = message as TaskStarted;
|
||||||
appendToSubAgentViewMessage(message)
|
if (msg.parent_task_id === currentSubAgentView.taskId) {
|
||||||
|
let matched = false;
|
||||||
|
setSubAgentStack((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const top = prev[prev.length - 1];
|
||||||
|
const updatedMessages = [...top.messages];
|
||||||
|
|
||||||
// 检查 pending navigation:当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
|
if (msg.tool_call_id) {
|
||||||
if (message.type === 'tool_call') {
|
const idx = updatedMessages.findIndex(
|
||||||
const tc = message as ToolCall
|
(m) =>
|
||||||
if (tc.tool_name === 'task' && tc.tool_call_id) {
|
m.toolCallId === msg.tool_call_id &&
|
||||||
const key = tc.tool_call_id
|
m.type === 'tool_call' &&
|
||||||
const pendingTaskId = pendingTaskNavsRef.current.get(key)
|
m.toolName === 'task',
|
||||||
if (pendingTaskId) {
|
);
|
||||||
pendingTaskNavsRef.current.delete(key)
|
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
|
||||||
setSubAgentStack((prev) => {
|
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id };
|
||||||
if (prev.length === 0) return prev
|
matched = true;
|
||||||
const top = prev[prev.length - 1]
|
const newStack = [...prev];
|
||||||
const updatedMessages = [...top.messages]
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
|
||||||
const idx = updatedMessages.findIndex(m =>
|
return newStack;
|
||||||
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
|
|
||||||
if (idx >= 0) {
|
|
||||||
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
|
|
||||||
const newStack = [...prev]
|
|
||||||
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
|
|
||||||
return newStack
|
|
||||||
}
|
}
|
||||||
return prev
|
}
|
||||||
})
|
for (let i = updatedMessages.length - 1; i >= 0; i--) {
|
||||||
|
const m = updatedMessages[i];
|
||||||
|
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
|
||||||
|
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id };
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newStack = [...prev];
|
||||||
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
|
||||||
|
return newStack;
|
||||||
|
});
|
||||||
|
if (!matched) {
|
||||||
|
const key = msg.tool_call_id || `fallback:${msg.task_id}`;
|
||||||
|
pendingTaskNavsRef.current.set(key, msg.task_id);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 子代理 todo_write 完成后自动刷新待办列表
|
const msgSubagentTaskId = getSubagentTaskId(message);
|
||||||
if (message.type === 'tool_result' && (message as { tool_name: string }).tool_name === 'todo_write') {
|
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
|
||||||
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
|
appendToSubAgentViewMessage(message);
|
||||||
sendCommand(refreshCmd)
|
|
||||||
|
// 检查 pending navigation:当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
|
||||||
|
if (message.type === 'tool_call') {
|
||||||
|
const tc = message as ToolCall;
|
||||||
|
if (tc.tool_name === 'task' && tc.tool_call_id) {
|
||||||
|
const key = tc.tool_call_id;
|
||||||
|
const pendingTaskId = pendingTaskNavsRef.current.get(key);
|
||||||
|
if (pendingTaskId) {
|
||||||
|
pendingTaskNavsRef.current.delete(key);
|
||||||
|
setSubAgentStack((prev) => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const top = prev[prev.length - 1];
|
||||||
|
const updatedMessages = [...top.messages];
|
||||||
|
const idx = updatedMessages.findIndex(
|
||||||
|
(m) => m.toolCallId === tc.tool_call_id && m.type === 'tool_call',
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
updatedMessages[idx] = {
|
||||||
|
...updatedMessages[idx],
|
||||||
|
navigateToTaskId: pendingTaskId,
|
||||||
|
};
|
||||||
|
const newStack = [...prev];
|
||||||
|
newStack[newStack.length - 1] = { ...top, messages: updatedMessages };
|
||||||
|
return newStack;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子代理 todo_write 完成后自动刷新待办列表
|
||||||
|
if (
|
||||||
|
message.type === 'tool_result' &&
|
||||||
|
(message as { tool_name: string }).tool_name === 'todo_write'
|
||||||
|
) {
|
||||||
|
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId);
|
||||||
|
sendCommand(refreshCmd);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId
|
// 非栈顶子智能体消息:遍历栈其余层查找匹配 taskId
|
||||||
if (msgSubagentTaskId) {
|
if (msgSubagentTaskId) {
|
||||||
appendToSubAgentLayerMessage(msgSubagentTaskId, message)
|
appendToSubAgentLayerMessage(msgSubagentTaskId, message);
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息不属于子智能体路由,fall through 到主视图
|
// 消息不属于子智能体路由,fall through 到主视图
|
||||||
return false
|
return false;
|
||||||
}, [appendToSubAgentViewMessage, appendToSubAgentLayerMessage, sendCommand, requestSubAgentTodoList])
|
},
|
||||||
|
[
|
||||||
|
appendToSubAgentViewMessage,
|
||||||
|
appendToSubAgentLayerMessage,
|
||||||
|
sendCommand,
|
||||||
|
requestSubAgentTodoList,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subAgentStack,
|
subAgentStack,
|
||||||
@ -383,5 +435,5 @@ export function useSubAgentView(options: UseSubAgentViewOptions): UseSubAgentVie
|
|||||||
exitSubAgentView,
|
exitSubAgentView,
|
||||||
navigateToSubAgentLevel,
|
navigateToSubAgentLevel,
|
||||||
handleSubAgentMessage,
|
handleSubAgentMessage,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,38 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, type Dispatch, type SetStateAction, type MutableRefObject } from 'react'
|
import {
|
||||||
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol'
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
type MutableRefObject,
|
||||||
|
} from 'react';
|
||||||
|
import type { Topic, TopicList, TopicRenamed, TopicSummary, Command } from '../../types/protocol';
|
||||||
|
|
||||||
export interface UseTopicsReturn {
|
export interface UseTopicsReturn {
|
||||||
topics: Topic[]
|
topics: Topic[];
|
||||||
setTopics: Dispatch<SetStateAction<Topic[]>>
|
setTopics: Dispatch<SetStateAction<Topic[]>>;
|
||||||
selectedTopic: string | null
|
selectedTopic: string | null;
|
||||||
setSelectedTopic: Dispatch<SetStateAction<string | null>>
|
setSelectedTopic: Dispatch<SetStateAction<string | null>>;
|
||||||
topicRefreshTrigger: number
|
topicRefreshTrigger: number;
|
||||||
bumpTopicRefreshTrigger: () => void
|
bumpTopicRefreshTrigger: () => void;
|
||||||
topicsRef: MutableRefObject<Topic[]>
|
topicsRef: MutableRefObject<Topic[]>;
|
||||||
selectedTopicRef: MutableRefObject<string | null>
|
selectedTopicRef: MutableRefObject<string | null>;
|
||||||
pendingNewTopicRef: MutableRefObject<boolean>
|
pendingNewTopicRef: MutableRefObject<boolean>;
|
||||||
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
/** 处理 topic_list 消息:映射格式并按 pendingNewTopic 自动聚焦,返回是否自动聚焦了新话题 */
|
||||||
handleTopicList: (msg: TopicList) => boolean
|
handleTopicList: (msg: TopicList) => boolean;
|
||||||
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */
|
/** 处理 topic_renamed 消息:用刷新后的列表替换本地状态(不改 selectedTopic) */
|
||||||
handleTopicRenamed: (msg: TopicRenamed) => void
|
handleTopicRenamed: (msg: TopicRenamed) => void;
|
||||||
createTopic: (title?: string) => Command
|
createTopic: (title?: string) => Command;
|
||||||
switchTopic: (topicId: string) => Command
|
switchTopic: (topicId: string) => Command;
|
||||||
deleteTopic: (topicId: string) => Command
|
deleteTopic: (topicId: string) => Command;
|
||||||
renameTopic: (topicId: string, title: string) => Command
|
renameTopic: (topicId: string, title: string) => Command;
|
||||||
requestTopicList: (sessionId: string | null) => Command | null
|
requestTopicList: (sessionId: string | null) => Command | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将后端 TopicSummary[] 映射为前端 Topic[] */
|
/** 将后端 TopicSummary[] 映射为前端 Topic[] */
|
||||||
function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
|
function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
|
||||||
return summaries.map(t => ({
|
return summaries.map((t) => ({
|
||||||
id: t.topic_id,
|
id: t.topic_id,
|
||||||
session_id: t.session_id,
|
session_id: t.session_id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
@ -32,75 +40,77 @@ function mapTopicSummaries(summaries: TopicSummary[]): Topic[] {
|
|||||||
message_count: Number(t.message_count),
|
message_count: Number(t.message_count),
|
||||||
created_at: t.created_at,
|
created_at: t.created_at,
|
||||||
updated_at: t.last_active_at,
|
updated_at: t.last_active_at,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTopics(): UseTopicsReturn {
|
export function useTopics(): UseTopicsReturn {
|
||||||
const [topics, setTopics] = useState<Topic[]>([])
|
const [topics, setTopics] = useState<Topic[]>([]);
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
|
||||||
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0)
|
const [topicRefreshTrigger, setTopicRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
const topicsRef = useRef<Topic[]>([])
|
const topicsRef = useRef<Topic[]>([]);
|
||||||
const selectedTopicRef = useRef<string | null>(null)
|
const selectedTopicRef = useRef<string | null>(null);
|
||||||
const pendingNewTopicRef = useRef(false)
|
const pendingNewTopicRef = useRef(false);
|
||||||
|
|
||||||
// ref 同步:确保回调中读到最新值
|
// ref 同步:确保回调中读到最新值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
topicsRef.current = topics
|
topicsRef.current = topics;
|
||||||
}, [topics])
|
}, [topics]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedTopicRef.current = selectedTopic
|
selectedTopicRef.current = selectedTopic;
|
||||||
}, [selectedTopic])
|
}, [selectedTopic]);
|
||||||
|
|
||||||
const bumpTopicRefreshTrigger = useCallback(() => {
|
const bumpTopicRefreshTrigger = useCallback(() => {
|
||||||
setTopicRefreshTrigger(n => n + 1)
|
setTopicRefreshTrigger((n) => n + 1);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
const handleTopicList = useCallback((msg: TopicList): boolean => {
|
||||||
const newTopics = mapTopicSummaries(msg.topics)
|
const newTopics = mapTopicSummaries(msg.topics);
|
||||||
setTopics(newTopics)
|
setTopics(newTopics);
|
||||||
|
|
||||||
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
// 新建话题后自动聚焦到新话题(列表按 last_active_at DESC 排序,第一个即最新)
|
||||||
if (pendingNewTopicRef.current) {
|
if (pendingNewTopicRef.current) {
|
||||||
pendingNewTopicRef.current = false
|
pendingNewTopicRef.current = false;
|
||||||
if (newTopics.length > 0) {
|
if (newTopics.length > 0) {
|
||||||
setSelectedTopic(newTopics[0].id)
|
setSelectedTopic(newTopics[0].id);
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
const handleTopicRenamed = useCallback((msg: TopicRenamed): void => {
|
||||||
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
// 后端返回刷新后的完整列表,直接替换;selectedTopic 基于 id 不变,无需调整
|
||||||
setTopics(mapTopicSummaries(msg.topics))
|
setTopics(mapTopicSummaries(msg.topics));
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const createTopic = useCallback((title?: string): Command => {
|
const createTopic = useCallback((title?: string): Command => {
|
||||||
pendingNewTopicRef.current = true
|
pendingNewTopicRef.current = true;
|
||||||
return {
|
return {
|
||||||
type: 'create_session',
|
type: 'create_session',
|
||||||
title: title || `话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
|
title:
|
||||||
}
|
title ||
|
||||||
}, [])
|
`话题 ${new Date().toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const switchTopic = useCallback((topicId: string): Command => {
|
const switchTopic = useCallback((topicId: string): Command => {
|
||||||
return { type: 'switch_topic', topic_id: topicId }
|
return { type: 'switch_topic', topic_id: topicId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const deleteTopic = useCallback((topicId: string): Command => {
|
const deleteTopic = useCallback((topicId: string): Command => {
|
||||||
return { type: 'delete_topic', topic_id: topicId }
|
return { type: 'delete_topic', topic_id: topicId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const renameTopic = useCallback((topicId: string, title: string): Command => {
|
const renameTopic = useCallback((topicId: string, title: string): Command => {
|
||||||
return { type: 'rename_topic', topic_id: topicId, title }
|
return { type: 'rename_topic', topic_id: topicId, title };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const requestTopicList = useCallback((sessionId: string | null): Command | null => {
|
const requestTopicList = useCallback((sessionId: string | null): Command | null => {
|
||||||
if (!sessionId) return null
|
if (!sessionId) return null;
|
||||||
return { type: 'list_topics', session_id: sessionId }
|
return { type: 'list_topics', session_id: sessionId };
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
topics,
|
topics,
|
||||||
@ -119,5 +129,5 @@ export function useTopics(): UseTopicsReturn {
|
|||||||
deleteTopic,
|
deleteTopic,
|
||||||
renameTopic,
|
renameTopic,
|
||||||
requestTopicList,
|
requestTopicList,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { renderHook, act } from '@testing-library/react'
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { useChat } from './useChat'
|
import { useChat } from './useChat';
|
||||||
import type {
|
import type {
|
||||||
WsInbound,
|
WsInbound,
|
||||||
SessionEstablished,
|
SessionEstablished,
|
||||||
@ -28,27 +28,27 @@ import type {
|
|||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
ExecutionCancelled,
|
ExecutionCancelled,
|
||||||
} from '../types/protocol'
|
} from '../types/protocol';
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
||||||
function renderUseChat() {
|
function renderUseChat() {
|
||||||
const sendMessage = vi.fn((_msg: WsInbound) => true)
|
const sendMessage = vi.fn((_msg: WsInbound) => true);
|
||||||
const { result } = renderHook(() => useChat())
|
const { result } = renderHook(() => useChat());
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setSendMessage(sendMessage)
|
result.current.setSendMessage(sendMessage);
|
||||||
})
|
});
|
||||||
return { result, sendMessage }
|
return { result, sendMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取出 sendMessage 收到的最后一条 command payload(已 JSON.parse) */
|
/** 取出 sendMessage 收到的最后一条 command payload(已 JSON.parse) */
|
||||||
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
|
function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
|
||||||
const calls = sendMessage.mock.calls
|
const calls = sendMessage.mock.calls;
|
||||||
const last = calls.length > 0 ? calls[calls.length - 1][0] as WsInbound : undefined
|
const last = calls.length > 0 ? (calls[calls.length - 1][0] as WsInbound) : undefined;
|
||||||
if (last && last.type === 'command') {
|
if (last && last.type === 'command') {
|
||||||
return JSON.parse(last.payload)
|
return JSON.parse(last.payload);
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- fixtures ----
|
// ---- fixtures ----
|
||||||
@ -56,7 +56,7 @@ function lastCommand(sendMessage: ReturnType<typeof vi.fn>): unknown {
|
|||||||
const sessionEstablished: SessionEstablished = {
|
const sessionEstablished: SessionEstablished = {
|
||||||
type: 'session_established',
|
type: 'session_established',
|
||||||
session_id: 'sess-1',
|
session_id: 'sess-1',
|
||||||
}
|
};
|
||||||
|
|
||||||
function makeSession(id: string): SessionSummary {
|
function makeSession(id: string): SessionSummary {
|
||||||
return {
|
return {
|
||||||
@ -66,13 +66,13 @@ function makeSession(id: string): SessionSummary {
|
|||||||
chat_id: `chat-${id}`,
|
chat_id: `chat-${id}`,
|
||||||
message_count: 0,
|
message_count: 0,
|
||||||
last_active_at: 1000,
|
last_active_at: 1000,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionList: SessionList = {
|
const sessionList: SessionList = {
|
||||||
type: 'session_list',
|
type: 'session_list',
|
||||||
sessions: [makeSession('s1'), makeSession('s2')],
|
sessions: [makeSession('s1'), makeSession('s2')],
|
||||||
}
|
};
|
||||||
|
|
||||||
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
|
function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
|
||||||
return {
|
return {
|
||||||
@ -82,32 +82,32 @@ function makeTopicSummary(id: string, sessionId = 's1'): TopicSummary {
|
|||||||
message_count: 0,
|
message_count: 0,
|
||||||
created_at: 1000,
|
created_at: 1000,
|
||||||
last_active_at: 2000,
|
last_active_at: 2000,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const topicList: TopicList = {
|
const topicList: TopicList = {
|
||||||
type: 'topic_list',
|
type: 'topic_list',
|
||||||
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
|
topics: [makeTopicSummary('t1'), makeTopicSummary('t2')],
|
||||||
session_id: 's1',
|
session_id: 's1',
|
||||||
}
|
};
|
||||||
|
|
||||||
const streamDelta1: StreamDelta = {
|
const streamDelta1: StreamDelta = {
|
||||||
type: 'stream_delta',
|
type: 'stream_delta',
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
delta: 'Hello',
|
delta: 'Hello',
|
||||||
}
|
};
|
||||||
const streamDelta2: StreamDelta = {
|
const streamDelta2: StreamDelta = {
|
||||||
type: 'stream_delta',
|
type: 'stream_delta',
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
delta: ' world',
|
delta: ' world',
|
||||||
}
|
};
|
||||||
|
|
||||||
const assistantResponse: AssistantResponse = {
|
const assistantResponse: AssistantResponse = {
|
||||||
type: 'assistant_response',
|
type: 'assistant_response',
|
||||||
id: 'm1',
|
id: 'm1',
|
||||||
content: 'Hello world',
|
content: 'Hello world',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
}
|
};
|
||||||
|
|
||||||
const toolCall: ToolCall = {
|
const toolCall: ToolCall = {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
@ -117,7 +117,7 @@ const toolCall: ToolCall = {
|
|||||||
arguments: { x: 1 },
|
arguments: { x: 1 },
|
||||||
content: 'calling calculator',
|
content: 'calling calculator',
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
}
|
};
|
||||||
|
|
||||||
const toolResult: ToolResult = {
|
const toolResult: ToolResult = {
|
||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
@ -126,7 +126,7 @@ const toolResult: ToolResult = {
|
|||||||
tool_name: 'calculator',
|
tool_name: 'calculator',
|
||||||
content: '42',
|
content: '42',
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
}
|
};
|
||||||
|
|
||||||
const toolPending: ToolPending = {
|
const toolPending: ToolPending = {
|
||||||
type: 'tool_pending',
|
type: 'tool_pending',
|
||||||
@ -136,38 +136,45 @@ const toolPending: ToolPending = {
|
|||||||
content: 'waiting',
|
content: 'waiting',
|
||||||
resume_hint: 'resume later',
|
resume_hint: 'resume later',
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
}
|
};
|
||||||
|
|
||||||
const errorMsg: WsError = {
|
const errorMsg: WsError = {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
code: 'ERR',
|
code: 'ERR',
|
||||||
message: 'something broke',
|
message: 'something broke',
|
||||||
}
|
};
|
||||||
|
|
||||||
const executionCancelled: ExecutionCancelled = {
|
const executionCancelled: ExecutionCancelled = {
|
||||||
type: 'execution_cancelled',
|
type: 'execution_cancelled',
|
||||||
message: 'stopped by user',
|
message: 'stopped by user',
|
||||||
}
|
};
|
||||||
|
|
||||||
const memoryList: MemoryList = {
|
const memoryList: MemoryList = {
|
||||||
type: 'memory_list',
|
type: 'memory_list',
|
||||||
memories: [
|
memories: [
|
||||||
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
|
{ id: 'mem1', namespace: 'ns', memory_key: 'k', content: 'c', created_at: 1, updated_at: 2 },
|
||||||
] as MemorySummary[],
|
] as MemorySummary[],
|
||||||
}
|
};
|
||||||
|
|
||||||
const skillList: SkillList = {
|
const skillList: SkillList = {
|
||||||
type: 'skill_list',
|
type: 'skill_list',
|
||||||
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
|
skills: [{ name: 'skill1', description: 'd', source: 'builtin' }] as SkillSummary[],
|
||||||
}
|
};
|
||||||
|
|
||||||
const todoList: TodoList = {
|
const todoList: TodoList = {
|
||||||
type: 'todo_list',
|
type: 'todo_list',
|
||||||
todos: [
|
todos: [
|
||||||
{ id: 'todo1', content: 'task', status: 'pending', priority: 'high', created_at: 1, updated_at: 2 },
|
{
|
||||||
|
id: 'todo1',
|
||||||
|
content: 'task',
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'high',
|
||||||
|
created_at: 1,
|
||||||
|
updated_at: 2,
|
||||||
|
},
|
||||||
] as TodoItemSummary[],
|
] as TodoItemSummary[],
|
||||||
scope_key: 'main',
|
scope_key: 'main',
|
||||||
}
|
};
|
||||||
|
|
||||||
const channelList: ChannelList = {
|
const channelList: ChannelList = {
|
||||||
type: 'channel_list',
|
type: 'channel_list',
|
||||||
@ -175,127 +182,135 @@ const channelList: ChannelList = {
|
|||||||
{ id: 'websocket', name: 'WebSocket', isWritable: true },
|
{ id: 'websocket', name: 'WebSocket', isWritable: true },
|
||||||
{ id: 'cli', name: 'CLI', isWritable: false },
|
{ id: 'cli', name: 'CLI', isWritable: false },
|
||||||
] as Channel[],
|
] as Channel[],
|
||||||
}
|
};
|
||||||
|
|
||||||
const schedulerJobList: SchedulerJobList = {
|
const schedulerJobList: SchedulerJobList = {
|
||||||
type: 'scheduler_job_list',
|
type: 'scheduler_job_list',
|
||||||
jobs: [
|
jobs: [
|
||||||
{ id: 'job1', kind: 'one_off', schedule: {}, enabled: true, state: 'idle', run_count: 0, created_at: 1 } as SchedulerJobSummary,
|
{
|
||||||
|
id: 'job1',
|
||||||
|
kind: 'one_off',
|
||||||
|
schedule: {},
|
||||||
|
enabled: true,
|
||||||
|
state: 'idle',
|
||||||
|
run_count: 0,
|
||||||
|
created_at: 1,
|
||||||
|
} as SchedulerJobSummary,
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
||||||
// ---- tests ----
|
// ---- tests ----
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks();
|
||||||
})
|
});
|
||||||
|
|
||||||
describe('useChat - handleServerMessage characterization', () => {
|
describe('useChat - handleServerMessage characterization', () => {
|
||||||
it('1. session_established sets connectionId and isConnected', () => {
|
it('1. session_established sets connectionId and isConnected', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
expect(result.current.isConnected).toBe(false)
|
expect(result.current.isConnected).toBe(false);
|
||||||
act(() => result.current.handleServerMessage(sessionEstablished))
|
act(() => result.current.handleServerMessage(sessionEstablished));
|
||||||
expect(result.current.connectionId).toBe('sess-1')
|
expect(result.current.connectionId).toBe('sess-1');
|
||||||
expect(result.current.isConnected).toBe(true)
|
expect(result.current.isConnected).toBe(true);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('2. session_list fills sessions and auto-selects the first', () => {
|
it('2. session_list fills sessions and auto-selects the first', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => result.current.handleServerMessage(sessionList))
|
act(() => result.current.handleServerMessage(sessionList));
|
||||||
expect(result.current.sessions).toHaveLength(2)
|
expect(result.current.sessions).toHaveLength(2);
|
||||||
expect(result.current.selectedSessionId).toBe('s1')
|
expect(result.current.selectedSessionId).toBe('s1');
|
||||||
expect(result.current.session?.session_id).toBe('s1')
|
expect(result.current.session?.session_id).toBe('s1');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => {
|
it('3. topic_list maps topics; after createTopic it auto-focuses the first (newest)', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
// establish session + topic list to set baseline
|
// establish session + topic list to set baseline
|
||||||
act(() => result.current.handleServerMessage(sessionEstablished))
|
act(() => result.current.handleServerMessage(sessionEstablished));
|
||||||
act(() => result.current.handleServerMessage(sessionList))
|
act(() => result.current.handleServerMessage(sessionList));
|
||||||
// first topic_list (without createTopic) sets topics but does NOT auto-select
|
// first topic_list (without createTopic) sets topics but does NOT auto-select
|
||||||
act(() => result.current.handleServerMessage(topicList))
|
act(() => result.current.handleServerMessage(topicList));
|
||||||
expect(result.current.topics).toHaveLength(2)
|
expect(result.current.topics).toHaveLength(2);
|
||||||
expect(result.current.selectedTopic).toBeNull()
|
expect(result.current.selectedTopic).toBeNull();
|
||||||
|
|
||||||
// simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives
|
// simulate createTopic flow: pendingNewTopicRef set true, then new topic_list arrives
|
||||||
act(() => result.current.createTopic('new topic'))
|
act(() => result.current.createTopic('new topic'));
|
||||||
const newTopicList: TopicList = {
|
const newTopicList: TopicList = {
|
||||||
type: 'topic_list',
|
type: 'topic_list',
|
||||||
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
|
topics: [makeTopicSummary('t3'), makeTopicSummary('t1'), makeTopicSummary('t2')],
|
||||||
session_id: 's1',
|
session_id: 's1',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(newTopicList))
|
act(() => result.current.handleServerMessage(newTopicList));
|
||||||
expect(result.current.selectedTopic).toBe('t3')
|
expect(result.current.selectedTopic).toBe('t3');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('4. stream_delta creates a message then accumulates into it by id', () => {
|
it('4. stream_delta creates a message then accumulates into it by id', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => result.current.handleServerMessage(streamDelta1))
|
act(() => result.current.handleServerMessage(streamDelta1));
|
||||||
expect(result.current.messages).toHaveLength(1)
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].content).toBe('Hello')
|
expect(result.current.messages[0].content).toBe('Hello');
|
||||||
act(() => result.current.handleServerMessage(streamDelta2))
|
act(() => result.current.handleServerMessage(streamDelta2));
|
||||||
expect(result.current.messages).toHaveLength(1)
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].content).toBe('Hello world')
|
expect(result.current.messages[0].content).toBe('Hello world');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('5. assistant_response replaces the streamed message by id', () => {
|
it('5. assistant_response replaces the streamed message by id', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => result.current.handleServerMessage(streamDelta1))
|
act(() => result.current.handleServerMessage(streamDelta1));
|
||||||
act(() => result.current.handleServerMessage(streamDelta2))
|
act(() => result.current.handleServerMessage(streamDelta2));
|
||||||
act(() => result.current.handleServerMessage(assistantResponse))
|
act(() => result.current.handleServerMessage(assistantResponse));
|
||||||
expect(result.current.messages).toHaveLength(1)
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].content).toBe('Hello world')
|
expect(result.current.messages[0].content).toBe('Hello world');
|
||||||
expect(result.current.messages[0].id).toBe('m1')
|
expect(result.current.messages[0].id).toBe('m1');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
|
it('6. tool_call / tool_result / tool_pending append corresponding message types', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => result.current.handleServerMessage(toolCall))
|
act(() => result.current.handleServerMessage(toolCall));
|
||||||
act(() => result.current.handleServerMessage(toolResult))
|
act(() => result.current.handleServerMessage(toolResult));
|
||||||
act(() => result.current.handleServerMessage(toolPending))
|
act(() => result.current.handleServerMessage(toolPending));
|
||||||
expect(result.current.messages).toHaveLength(3)
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[0].type).toBe('tool_call')
|
expect(result.current.messages[0].type).toBe('tool_call');
|
||||||
expect(result.current.messages[0].toolName).toBe('calculator')
|
expect(result.current.messages[0].toolName).toBe('calculator');
|
||||||
expect(result.current.messages[1].type).toBe('tool_result')
|
expect(result.current.messages[1].type).toBe('tool_result');
|
||||||
expect(result.current.messages[2].type).toBe('tool_pending')
|
expect(result.current.messages[2].type).toBe('tool_pending');
|
||||||
expect(result.current.messages[2].content).toContain('resume later')
|
expect(result.current.messages[2].content).toContain('resume later');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('7. error and execution_cancelled append a message and clear isLoading', () => {
|
it('7. error and execution_cancelled append a message and clear isLoading', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
// set isLoading true via handleMessage
|
// set isLoading true via handleMessage
|
||||||
act(() => result.current.handleMessage('hi'))
|
act(() => result.current.handleMessage('hi'));
|
||||||
expect(result.current.isLoading).toBe(true)
|
expect(result.current.isLoading).toBe(true);
|
||||||
act(() => result.current.handleServerMessage(errorMsg))
|
act(() => result.current.handleServerMessage(errorMsg));
|
||||||
expect(result.current.isLoading).toBe(false)
|
expect(result.current.isLoading).toBe(false);
|
||||||
const errMsg = result.current.messages[result.current.messages.length - 1]
|
const errMsg = result.current.messages[result.current.messages.length - 1];
|
||||||
expect(errMsg?.content).toBe('Error: something broke')
|
expect(errMsg?.content).toBe('Error: something broke');
|
||||||
|
|
||||||
// reset isLoading + cleared, then test execution_cancelled
|
// reset isLoading + cleared, then test execution_cancelled
|
||||||
act(() => result.current.handleMessage('hi again'))
|
act(() => result.current.handleMessage('hi again'));
|
||||||
expect(result.current.isLoading).toBe(true)
|
expect(result.current.isLoading).toBe(true);
|
||||||
act(() => result.current.handleServerMessage(executionCancelled))
|
act(() => result.current.handleServerMessage(executionCancelled));
|
||||||
expect(result.current.isLoading).toBe(false)
|
expect(result.current.isLoading).toBe(false);
|
||||||
const cancelMsg = result.current.messages[result.current.messages.length - 1]
|
const cancelMsg = result.current.messages[result.current.messages.length - 1];
|
||||||
expect(cancelMsg?.content).toBe('stopped by user')
|
expect(cancelMsg?.content).toBe('stopped by user');
|
||||||
})
|
});
|
||||||
|
|
||||||
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
|
it('8. memory_list / skill_list / todo_list / channel_list / scheduler_job_list set corresponding state', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => result.current.handleServerMessage(memoryList))
|
act(() => result.current.handleServerMessage(memoryList));
|
||||||
act(() => result.current.handleServerMessage(skillList))
|
act(() => result.current.handleServerMessage(skillList));
|
||||||
act(() => result.current.handleServerMessage(todoList))
|
act(() => result.current.handleServerMessage(todoList));
|
||||||
act(() => result.current.handleServerMessage(channelList))
|
act(() => result.current.handleServerMessage(channelList));
|
||||||
act(() => result.current.handleServerMessage(schedulerJobList))
|
act(() => result.current.handleServerMessage(schedulerJobList));
|
||||||
expect(result.current.memories).toHaveLength(1)
|
expect(result.current.memories).toHaveLength(1);
|
||||||
expect(result.current.skills).toHaveLength(1)
|
expect(result.current.skills).toHaveLength(1);
|
||||||
expect(result.current.todos).toHaveLength(1)
|
expect(result.current.todos).toHaveLength(1);
|
||||||
expect(result.current.channels).toHaveLength(2)
|
expect(result.current.channels).toHaveLength(2);
|
||||||
expect(result.current.schedulerJobs).toHaveLength(1)
|
expect(result.current.schedulerJobs).toHaveLength(1);
|
||||||
})
|
});
|
||||||
|
|
||||||
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
|
it('9. task_started (main view, no parent) backfills navigateToTaskId on matching task tool_call', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
const taskToolCall: ToolCall = {
|
const taskToolCall: ToolCall = {
|
||||||
type: 'tool_call',
|
type: 'tool_call',
|
||||||
id: 'tc-task',
|
id: 'tc-task',
|
||||||
@ -304,9 +319,11 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
arguments: { prompt: 'do sub' },
|
arguments: { prompt: 'do sub' },
|
||||||
content: 'spawning sub',
|
content: 'spawning sub',
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(taskToolCall))
|
act(() => result.current.handleServerMessage(taskToolCall));
|
||||||
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBeUndefined()
|
expect(
|
||||||
|
result.current.messages[result.current.messages.length - 1]?.navigateToTaskId,
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
const taskStarted: TaskStarted = {
|
const taskStarted: TaskStarted = {
|
||||||
type: 'task_started',
|
type: 'task_started',
|
||||||
@ -314,16 +331,18 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
description: 'sub agent',
|
description: 'sub agent',
|
||||||
subagent_type: 'general',
|
subagent_type: 'general',
|
||||||
tool_call_id: 'tc-task',
|
tool_call_id: 'tc-task',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(taskStarted))
|
act(() => result.current.handleServerMessage(taskStarted));
|
||||||
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe('sub-1')
|
expect(result.current.messages[result.current.messages.length - 1]?.navigateToTaskId).toBe(
|
||||||
})
|
'sub-1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => {
|
it('10. sub-agent view: task_messages_loaded updates stack top; tagged messages route to sub view not main', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
// enter sub-agent view for task "sub-1"
|
// enter sub-agent view for task "sub-1"
|
||||||
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'))
|
act(() => result.current.enterSubAgentView('sub-1', 'sub agent', 'general'));
|
||||||
expect(result.current.subAgentView?.taskId).toBe('sub-1')
|
expect(result.current.subAgentView?.taskId).toBe('sub-1');
|
||||||
|
|
||||||
// task_messages_loaded updates top metadata
|
// task_messages_loaded updates top metadata
|
||||||
const loaded: TaskMessagesLoaded = {
|
const loaded: TaskMessagesLoaded = {
|
||||||
@ -333,10 +352,10 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
subagent_type: 'general',
|
subagent_type: 'general',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
summary: 'working',
|
summary: 'working',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(loaded))
|
act(() => result.current.handleServerMessage(loaded));
|
||||||
expect(result.current.subAgentView?.status).toBe('running')
|
expect(result.current.subAgentView?.status).toBe('running');
|
||||||
expect(result.current.subAgentView?.summary).toBe('working')
|
expect(result.current.subAgentView?.summary).toBe('working');
|
||||||
|
|
||||||
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
|
// a stream_delta tagged with subagent_task_id === 'sub-1' goes to sub view, not main
|
||||||
const subStream: StreamDelta = {
|
const subStream: StreamDelta = {
|
||||||
@ -344,33 +363,33 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
id: 'sub-m1',
|
id: 'sub-m1',
|
||||||
delta: 'sub hello',
|
delta: 'sub hello',
|
||||||
subagent_task_id: 'sub-1',
|
subagent_task_id: 'sub-1',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(subStream))
|
act(() => result.current.handleServerMessage(subStream));
|
||||||
expect(result.current.subAgentView?.messages).toHaveLength(1)
|
expect(result.current.subAgentView?.messages).toHaveLength(1);
|
||||||
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello')
|
expect(result.current.subAgentView?.messages[0].content).toBe('sub hello');
|
||||||
|
|
||||||
// exit back to main: main messages should not contain the sub-agent message
|
// exit back to main: main messages should not contain the sub-agent message
|
||||||
act(() => result.current.exitSubAgentView())
|
act(() => result.current.exitSubAgentView());
|
||||||
expect(result.current.messages.find(m => m.id === 'sub-m1')).toBeUndefined()
|
expect(result.current.messages.find((m) => m.id === 'sub-m1')).toBeUndefined();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
|
it('11. scheduler view: chat messages route into schedulerView.messages, not main', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' }
|
const lookup: SchedulerJobSessionLookup = { channel: 'scheduler', chat_id: 'job-chat' };
|
||||||
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'))
|
act(() => result.current.enterSchedulerJobView(lookup, 'job1', 'job desc'));
|
||||||
expect(result.current.schedulerView).not.toBeNull()
|
expect(result.current.schedulerView).not.toBeNull();
|
||||||
|
|
||||||
act(() => result.current.handleServerMessage(assistantResponse))
|
act(() => result.current.handleServerMessage(assistantResponse));
|
||||||
expect(result.current.schedulerView?.messages).toHaveLength(1)
|
expect(result.current.schedulerView?.messages).toHaveLength(1);
|
||||||
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world')
|
expect(result.current.schedulerView?.messages[0].content).toBe('Hello world');
|
||||||
|
|
||||||
// exit scheduler view: main messages should not contain the routed message
|
// exit scheduler view: main messages should not contain the routed message
|
||||||
act(() => result.current.exitSchedulerJobView())
|
act(() => result.current.exitSchedulerJobView());
|
||||||
expect(result.current.messages.find(m => m.id === 'm1')).toBeUndefined()
|
expect(result.current.messages.find((m) => m.id === 'm1')).toBeUndefined();
|
||||||
})
|
});
|
||||||
|
|
||||||
it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => {
|
it('12. tool_result with tool_name=todo_write triggers a list_todos command in main view', () => {
|
||||||
const { result, sendMessage } = renderUseChat()
|
const { result, sendMessage } = renderUseChat();
|
||||||
const todoWriteResult: ToolResult = {
|
const todoWriteResult: ToolResult = {
|
||||||
type: 'tool_result',
|
type: 'tool_result',
|
||||||
id: 'tr-todo',
|
id: 'tr-todo',
|
||||||
@ -378,30 +397,30 @@ describe('useChat - handleServerMessage characterization', () => {
|
|||||||
tool_name: 'todo_write',
|
tool_name: 'todo_write',
|
||||||
content: 'updated',
|
content: 'updated',
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(todoWriteResult))
|
act(() => result.current.handleServerMessage(todoWriteResult));
|
||||||
const cmd = lastCommand(sendMessage)
|
const cmd = lastCommand(sendMessage);
|
||||||
expect(cmd).toEqual({ type: 'list_todos' })
|
expect(cmd).toEqual({ type: 'list_todos' });
|
||||||
})
|
});
|
||||||
|
|
||||||
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
|
it('13. stream_delta whose topic_id does not match selectedTopic is discarded', () => {
|
||||||
const { result } = renderUseChat()
|
const { result } = renderUseChat();
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleServerMessage(sessionEstablished)
|
result.current.handleServerMessage(sessionEstablished);
|
||||||
result.current.handleServerMessage(sessionList)
|
result.current.handleServerMessage(sessionList);
|
||||||
result.current.handleServerMessage(topicList)
|
result.current.handleServerMessage(topicList);
|
||||||
})
|
});
|
||||||
// topic_list without createTopic does NOT auto-select; manually select t1
|
// topic_list without createTopic does NOT auto-select; manually select t1
|
||||||
act(() => result.current.selectTopic('t1'))
|
act(() => result.current.selectTopic('t1'));
|
||||||
expect(result.current.selectedTopic).toBe('t1')
|
expect(result.current.selectedTopic).toBe('t1');
|
||||||
|
|
||||||
const otherTopicStream: StreamDelta = {
|
const otherTopicStream: StreamDelta = {
|
||||||
type: 'stream_delta',
|
type: 'stream_delta',
|
||||||
id: 'm-other',
|
id: 'm-other',
|
||||||
delta: 'should be dropped',
|
delta: 'should be dropped',
|
||||||
topic_id: 't-other',
|
topic_id: 't-other',
|
||||||
}
|
};
|
||||||
act(() => result.current.handleServerMessage(otherTopicStream))
|
act(() => result.current.handleServerMessage(otherTopicStream));
|
||||||
expect(result.current.messages.find(m => m.id === 'm-other')).toBeUndefined()
|
expect(result.current.messages.find((m) => m.id === 'm-other')).toBeUndefined();
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react'
|
import { useCallback, useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||||
import type {
|
import type {
|
||||||
Command,
|
Command,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@ -13,201 +13,207 @@ import type {
|
|||||||
SchedulerJobSummary,
|
SchedulerJobSummary,
|
||||||
SchedulerJobSessionLookup,
|
SchedulerJobSessionLookup,
|
||||||
Channel,
|
Channel,
|
||||||
} from '../types/protocol'
|
} from '../types/protocol';
|
||||||
import type { SubAgentView, SchedulerJobView } from './chat/types'
|
import type { SubAgentView, SchedulerJobView } from './chat/types';
|
||||||
import { getSubagentTaskId } from './chat/messageMappers'
|
import { getSubagentTaskId } from './chat/messageMappers';
|
||||||
import { useConnection } from './chat/useConnection'
|
import { useConnection } from './chat/useConnection';
|
||||||
import { useSideData } from './chat/useSideData'
|
import { useSideData } from './chat/useSideData';
|
||||||
import { useSessions } from './chat/useSessions'
|
import { useSessions } from './chat/useSessions';
|
||||||
import { useTopics } from './chat/useTopics'
|
import { useTopics } from './chat/useTopics';
|
||||||
import { useMessages } from './chat/useMessages'
|
import { useMessages } from './chat/useMessages';
|
||||||
import { useSubAgentView } from './chat/useSubAgentView'
|
import { useSubAgentView } from './chat/useSubAgentView';
|
||||||
import { useSchedulerView } from './chat/useSchedulerView'
|
import { useSchedulerView } from './chat/useSchedulerView';
|
||||||
|
|
||||||
// 简化后的层级状态
|
// 简化后的层级状态
|
||||||
interface UseChatReturn {
|
interface UseChatReturn {
|
||||||
// 连接状态
|
// 连接状态
|
||||||
connectionId: string | null
|
connectionId: string | null;
|
||||||
isConnected: boolean
|
isConnected: boolean;
|
||||||
|
|
||||||
// 简化的层级状态
|
// 简化的层级状态
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[];
|
||||||
selectedSessionId: string | null
|
selectedSessionId: string | null;
|
||||||
session: SessionSummary | null
|
session: SessionSummary | null;
|
||||||
sessionId: string | null
|
sessionId: string | null;
|
||||||
chatId: string
|
chatId: string;
|
||||||
topics: Topic[]
|
topics: Topic[];
|
||||||
selectedTopic: string | null
|
selectedTopic: string | null;
|
||||||
|
|
||||||
// 消息
|
// 消息
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[];
|
||||||
isLoading: boolean
|
isLoading: boolean;
|
||||||
|
|
||||||
// 通道状态
|
// 通道状态
|
||||||
channels: Channel[]
|
channels: Channel[];
|
||||||
selectedChannel: string
|
selectedChannel: string;
|
||||||
isWritable: boolean
|
isWritable: boolean;
|
||||||
|
|
||||||
// 是否只读
|
// 是否只读
|
||||||
isReadOnly: boolean
|
isReadOnly: boolean;
|
||||||
|
|
||||||
// 子智能体视图(栈结构,支持面包屑导航)
|
// 子智能体视图(栈结构,支持面包屑导航)
|
||||||
subAgentView: SubAgentView | null
|
subAgentView: SubAgentView | null;
|
||||||
subAgentStack: SubAgentView[]
|
subAgentStack: SubAgentView[];
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
handleMessage: (content: string, attachments?: Attachment[]) => void
|
handleMessage: (content: string, attachments?: Attachment[]) => void;
|
||||||
handleCommand: (command: Command) => void
|
handleCommand: (command: Command) => void;
|
||||||
clearMessages: () => void
|
clearMessages: () => void;
|
||||||
handleServerMessage: (message: WsOutbound) => void
|
handleServerMessage: (message: WsOutbound) => void;
|
||||||
setSendMessage: (fn: (msg: WsInbound) => boolean) => void
|
setSendMessage: (fn: (msg: WsInbound) => boolean) => void;
|
||||||
|
|
||||||
// Topic 方法
|
// Topic 方法
|
||||||
selectTopic: (topicId: string) => void
|
selectTopic: (topicId: string) => void;
|
||||||
createTopic: (title?: string) => Command
|
createTopic: (title?: string) => Command;
|
||||||
switchTopic: (topicId: string) => Command
|
switchTopic: (topicId: string) => Command;
|
||||||
deleteTopic: (topicId: string) => Command
|
deleteTopic: (topicId: string) => Command;
|
||||||
renameTopic: (topicId: string, title: string) => Command
|
renameTopic: (topicId: string, title: string) => Command;
|
||||||
|
|
||||||
// 初始化方法
|
// 初始化方法
|
||||||
requestSessionList: () => Command
|
requestSessionList: () => Command;
|
||||||
requestTopicList: () => Command | null
|
requestTopicList: () => Command | null;
|
||||||
topicRefreshTrigger: number
|
topicRefreshTrigger: number;
|
||||||
requestChannelList: () => Command
|
requestChannelList: () => Command;
|
||||||
selectChannel: (channelId: string) => void
|
selectChannel: (channelId: string) => void;
|
||||||
selectSession: (sessionId: string) => void
|
selectSession: (sessionId: string) => void;
|
||||||
|
|
||||||
// 子智能体导航方法
|
// 子智能体导航方法
|
||||||
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command
|
enterSubAgentView: (taskId: string, description: string, subagentType?: string) => Command;
|
||||||
exitSubAgentView: () => Command | null
|
exitSubAgentView: () => Command | null;
|
||||||
navigateToSubAgentLevel: (index: number) => Command | null
|
navigateToSubAgentLevel: (index: number) => Command | null;
|
||||||
|
|
||||||
// 记忆状态
|
// 记忆状态
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[];
|
||||||
requestMemoryList: () => Command
|
requestMemoryList: () => Command;
|
||||||
createMemory: (namespace: string, key: string, content: string) => Command
|
createMemory: (namespace: string, key: string, content: string) => Command;
|
||||||
updateMemory: (id: string, content: string) => Command
|
updateMemory: (id: string, content: string) => Command;
|
||||||
deleteMemory: (id: string) => Command
|
deleteMemory: (id: string) => Command;
|
||||||
|
|
||||||
// 技能状态
|
// 技能状态
|
||||||
skills: SkillSummary[]
|
skills: SkillSummary[];
|
||||||
requestSkillList: () => Command
|
requestSkillList: () => Command;
|
||||||
|
|
||||||
// Todo 状态
|
// Todo 状态
|
||||||
todos: TodoItemSummary[]
|
todos: TodoItemSummary[];
|
||||||
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>
|
setTodos: Dispatch<SetStateAction<TodoItemSummary[]>>;
|
||||||
requestTodoList: () => Command
|
requestTodoList: () => Command;
|
||||||
requestSubAgentTodoList: (subTaskId: string) => Command
|
requestSubAgentTodoList: (subTaskId: string) => Command;
|
||||||
|
|
||||||
// 高亮消息 ID(点击待办后滚动到对应消息)
|
// 高亮消息 ID(点击待办后滚动到对应消息)
|
||||||
highlightedMessageId: string | null
|
highlightedMessageId: string | null;
|
||||||
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
|
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>;
|
||||||
|
|
||||||
// 定时任务状态
|
// 定时任务状态
|
||||||
schedulerJobs: SchedulerJobSummary[]
|
schedulerJobs: SchedulerJobSummary[];
|
||||||
sidebarTab: 'topics' | 'scheduler'
|
sidebarTab: 'topics' | 'scheduler';
|
||||||
setSidebarTab: (tab: 'topics' | 'scheduler') => void
|
setSidebarTab: (tab: 'topics' | 'scheduler') => void;
|
||||||
requestSchedulerJobList: () => Command
|
requestSchedulerJobList: () => Command;
|
||||||
|
|
||||||
// 定时任务执行对话查看
|
// 定时任务执行对话查看
|
||||||
schedulerView: SchedulerJobView | null
|
schedulerView: SchedulerJobView | null;
|
||||||
enterSchedulerJobView: (lookup: SchedulerJobSessionLookup, jobId: string, description: string) => Command
|
enterSchedulerJobView: (
|
||||||
exitSchedulerJobView: () => void
|
lookup: SchedulerJobSessionLookup,
|
||||||
|
jobId: string,
|
||||||
|
description: string,
|
||||||
|
) => Command;
|
||||||
|
exitSchedulerJobView: () => void;
|
||||||
|
|
||||||
// 停止当前 Agent 执行
|
// 停止当前 Agent 执行
|
||||||
handleStop: () => Command
|
handleStop: () => Command;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChat(): UseChatReturn {
|
export function useChat(): UseChatReturn {
|
||||||
// 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList)
|
// 调用顺序确保依赖方向:useSideData 在 useSubAgentView 之前(后者依赖 requestSubAgentTodoList)
|
||||||
const conn = useConnection()
|
const conn = useConnection();
|
||||||
const sideData = useSideData()
|
const sideData = useSideData();
|
||||||
const sessions = useSessions()
|
const sessions = useSessions();
|
||||||
const topics = useTopics()
|
const topics = useTopics();
|
||||||
const messages = useMessages({
|
const messages = useMessages({
|
||||||
selectedTopicRef: topics.selectedTopicRef,
|
selectedTopicRef: topics.selectedTopicRef,
|
||||||
topicsRef: topics.topicsRef,
|
topicsRef: topics.topicsRef,
|
||||||
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
|
bumpTopicRefreshTrigger: topics.bumpTopicRefreshTrigger,
|
||||||
})
|
});
|
||||||
const subAgent = useSubAgentView({
|
const subAgent = useSubAgentView({
|
||||||
sendCommand: conn.sendCommand,
|
sendCommand: conn.sendCommand,
|
||||||
requestSubAgentTodoList: sideData.requestSubAgentTodoList,
|
requestSubAgentTodoList: sideData.requestSubAgentTodoList,
|
||||||
})
|
});
|
||||||
const scheduler = useSchedulerView()
|
const scheduler = useSchedulerView();
|
||||||
|
|
||||||
// ---- handleServerMessage: 纯路由分发(Tier 1 → Tier 2 → Tier 3) ----
|
// ---- handleServerMessage: 纯路由分发(Tier 1 → Tier 2 → Tier 3) ----
|
||||||
// 所有被调用的方法都是稳定引用(useState setter 或 useCallback([])),
|
// 所有被调用的方法都是稳定引用(useState setter 或 useCallback([])),
|
||||||
// 因此空依赖数组安全,不会产生过期闭包。
|
// 因此空依赖数组安全,不会产生过期闭包。
|
||||||
const handleServerMessage = useCallback((message: WsOutbound) => {
|
const handleServerMessage = useCallback((message: WsOutbound) => {
|
||||||
// Tier 1: 调度器视图激活时,chat 消息路由到 schedulerView
|
// Tier 1: 调度器视图激活时,chat 消息路由到 schedulerView
|
||||||
if (scheduler.handleSchedulerMessage(message)) return
|
if (scheduler.handleSchedulerMessage(message)) return;
|
||||||
|
|
||||||
// Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack
|
// Tier 2: 子智能体视图激活时,匹配的消息路由到 subAgentStack
|
||||||
if (subAgent.handleSubAgentMessage(message)) return
|
if (subAgent.handleSubAgentMessage(message)) return;
|
||||||
|
|
||||||
// Tier 3: 主视图路由
|
// Tier 3: 主视图路由
|
||||||
// 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中)
|
// 3a: 带 subagent_task_id 的消息在主视图直接丢弃(已在 Tier 2 未命中)
|
||||||
if (getSubagentTaskId(message)) return
|
if (getSubagentTaskId(message)) return;
|
||||||
|
|
||||||
// 3b: 非 chat 消息的 case 分发
|
// 3b: 非 chat 消息的 case 分发
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'session_established':
|
case 'session_established':
|
||||||
conn.setConnectionId(message.session_id)
|
conn.setConnectionId(message.session_id);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'session_list':
|
case 'session_list':
|
||||||
// 清空旧数据(切换通道时避免数据污染)
|
// 清空旧数据(切换通道时避免数据污染)
|
||||||
topics.setTopics([])
|
topics.setTopics([]);
|
||||||
topics.setSelectedTopic(null)
|
topics.setSelectedTopic(null);
|
||||||
messages.setMessages([])
|
messages.setMessages([]);
|
||||||
sessions.setSessions(message.sessions)
|
sessions.setSessions(message.sessions);
|
||||||
// 自动选中:优先保持当前选中,否则选第一个
|
// 自动选中:优先保持当前选中,否则选第一个
|
||||||
sessions.setSelectedSessionId(prev =>
|
sessions.setSelectedSessionId((prev) =>
|
||||||
prev && message.sessions.some(s => s.session_id === prev)
|
prev && message.sessions.some((s) => s.session_id === prev)
|
||||||
? prev
|
? prev
|
||||||
: message.sessions.length > 0 ? message.sessions[0].session_id : null
|
: message.sessions.length > 0
|
||||||
)
|
? message.sessions[0].session_id
|
||||||
messages.setIsLoading(false)
|
: null,
|
||||||
return
|
);
|
||||||
|
messages.setIsLoading(false);
|
||||||
|
return;
|
||||||
|
|
||||||
case 'session_created':
|
case 'session_created':
|
||||||
case 'session_loaded':
|
case 'session_loaded':
|
||||||
messages.setIsLoading(false)
|
messages.setIsLoading(false);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'topic_list': {
|
case 'topic_list': {
|
||||||
const autoFocused = topics.handleTopicList(message)
|
const autoFocused = topics.handleTopicList(message);
|
||||||
if (autoFocused) messages.setMessages([])
|
if (autoFocused) messages.setMessages([]);
|
||||||
messages.setIsLoading(false)
|
messages.setIsLoading(false);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'topic_renamed':
|
case 'topic_renamed':
|
||||||
topics.handleTopicRenamed(message)
|
topics.handleTopicRenamed(message);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'scheduler_job_list':
|
case 'scheduler_job_list':
|
||||||
scheduler.setSchedulerJobs(message.jobs)
|
scheduler.setSchedulerJobs(message.jobs);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'memory_list':
|
case 'memory_list':
|
||||||
sideData.setMemories(message.memories)
|
sideData.setMemories(message.memories);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'skill_list':
|
case 'skill_list':
|
||||||
sideData.setSkills(message.skills)
|
sideData.setSkills(message.skills);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'todo_list':
|
case 'todo_list':
|
||||||
sideData.setTodos(message.todos)
|
sideData.setTodos(message.todos);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'channel_list':
|
case 'channel_list':
|
||||||
sideData.setChannels(message.channels)
|
sideData.setChannels(message.channels);
|
||||||
return
|
return;
|
||||||
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
return
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 3c: chat 类消息(task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled)
|
// 3c: chat 类消息(task_started/stream_*/tool_*/execution_*/error/assistant_response/execution_cancelled)
|
||||||
@ -216,17 +222,20 @@ export function useChat(): UseChatReturn {
|
|||||||
// 注意:topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true,
|
// 注意:topic_id 不匹配时 handleMainViewMessage 会丢弃消息并返回 true,
|
||||||
// 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数,
|
// 但原实现中 tool_result case 的 topic_id 检查会 return 退出整个函数,
|
||||||
// 因此这里需要再次检查 topic_id 以保持行为等价。
|
// 因此这里需要再次检查 topic_id 以保持行为等价。
|
||||||
if (message.type === 'tool_result' && message.tool_name === 'todo_write'
|
if (
|
||||||
&& (!message.topic_id || message.topic_id === topics.selectedTopicRef.current)) {
|
message.type === 'tool_result' &&
|
||||||
|
message.tool_name === 'todo_write' &&
|
||||||
|
(!message.topic_id || message.topic_id === topics.selectedTopicRef.current)
|
||||||
|
) {
|
||||||
const cmd = subAgent.subAgentViewRef.current?.taskId
|
const cmd = subAgent.subAgentViewRef.current?.taskId
|
||||||
? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId)
|
? sideData.requestSubAgentTodoList(subAgent.subAgentViewRef.current.taskId)
|
||||||
: sideData.requestTodoList()
|
: sideData.requestTodoList();
|
||||||
conn.sendCommand(cmd)
|
conn.sendCommand(cmd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- handleCommand: 根据命令类型设置 loading 状态 ----
|
// ---- handleCommand: 根据命令类型设置 loading 状态 ----
|
||||||
const handleCommand = useCallback((command: Command) => {
|
const handleCommand = useCallback((command: Command) => {
|
||||||
@ -238,70 +247,76 @@ export function useChat(): UseChatReturn {
|
|||||||
case 'list_sessions_by_channel':
|
case 'list_sessions_by_channel':
|
||||||
case 'delete_topic':
|
case 'delete_topic':
|
||||||
case 'list_topics':
|
case 'list_topics':
|
||||||
messages.setIsLoading(true)
|
messages.setIsLoading(true);
|
||||||
break
|
break;
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
// ---- selectTopic: 切换话题,清空消息和子智能体栈 ----
|
||||||
const selectTopic = useCallback((topicId: string) => {
|
const selectTopic = useCallback((topicId: string) => {
|
||||||
topics.setSelectedTopic(topicId)
|
topics.setSelectedTopic(topicId);
|
||||||
messages.setMessages([])
|
messages.setMessages([]);
|
||||||
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
|
// ref + state 双写,消除竞态窗口(与 enter/exitSubAgentView 一致)
|
||||||
subAgent.subAgentViewRef.current = null
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.subAgentStackRef.current = []
|
subAgent.subAgentStackRef.current = [];
|
||||||
subAgent.setSubAgentStack([])
|
subAgent.setSubAgentStack([]);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- selectChannel: 切换通道,清空全部状态 ----
|
// ---- selectChannel: 切换通道,清空全部状态 ----
|
||||||
const selectChannel = useCallback((channelId: string) => {
|
const selectChannel = useCallback(
|
||||||
if (channelId === sideData.selectedChannel) return
|
(channelId: string) => {
|
||||||
sideData.setSelectedChannel(channelId)
|
if (channelId === sideData.selectedChannel) return;
|
||||||
sessions.setSessions([])
|
sideData.setSelectedChannel(channelId);
|
||||||
sessions.setSelectedSessionId(null)
|
sessions.setSessions([]);
|
||||||
topics.setTopics([])
|
sessions.setSelectedSessionId(null);
|
||||||
topics.setSelectedTopic(null)
|
topics.setTopics([]);
|
||||||
messages.setMessages([])
|
topics.setSelectedTopic(null);
|
||||||
subAgent.subAgentViewRef.current = null
|
messages.setMessages([]);
|
||||||
subAgent.subAgentStackRef.current = []
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.setSubAgentStack([])
|
subAgent.subAgentStackRef.current = [];
|
||||||
messages.setIsLoading(true)
|
subAgent.setSubAgentStack([]);
|
||||||
}, [sideData.selectedChannel])
|
messages.setIsLoading(true);
|
||||||
|
},
|
||||||
|
[sideData.selectedChannel],
|
||||||
|
);
|
||||||
|
|
||||||
// ---- selectSession: 切换会话,清空 topics/messages/subAgent ----
|
// ---- selectSession: 切换会话,清空 topics/messages/subAgent ----
|
||||||
const selectSession = useCallback((sessionId: string) => {
|
const selectSession = useCallback(
|
||||||
if (sessionId === sessions.selectedSessionId) return
|
(sessionId: string) => {
|
||||||
sessions.setSelectedSessionId(sessionId)
|
if (sessionId === sessions.selectedSessionId) return;
|
||||||
topics.setTopics([])
|
sessions.setSelectedSessionId(sessionId);
|
||||||
topics.setSelectedTopic(null)
|
topics.setTopics([]);
|
||||||
messages.setMessages([])
|
topics.setSelectedTopic(null);
|
||||||
subAgent.subAgentViewRef.current = null
|
messages.setMessages([]);
|
||||||
subAgent.subAgentStackRef.current = []
|
subAgent.subAgentViewRef.current = null;
|
||||||
subAgent.setSubAgentStack([])
|
subAgent.subAgentStackRef.current = [];
|
||||||
messages.setIsLoading(true)
|
subAgent.setSubAgentStack([]);
|
||||||
}, [sessions.selectedSessionId])
|
messages.setIsLoading(true);
|
||||||
|
},
|
||||||
|
[sessions.selectedSessionId],
|
||||||
|
);
|
||||||
|
|
||||||
// ---- 委托方法 ----
|
// ---- 委托方法 ----
|
||||||
const requestSessionList = useCallback((): Command => {
|
const requestSessionList = useCallback((): Command => {
|
||||||
return sessions.requestSessionList(sideData.selectedChannel)
|
return sessions.requestSessionList(sideData.selectedChannel);
|
||||||
}, [sideData.selectedChannel])
|
}, [sideData.selectedChannel]);
|
||||||
|
|
||||||
const requestTopicList = useCallback((): Command | null => {
|
const requestTopicList = useCallback((): Command | null => {
|
||||||
return topics.requestTopicList(sessions.sessionId)
|
return topics.requestTopicList(sessions.sessionId);
|
||||||
}, [sessions.sessionId])
|
}, [sessions.sessionId]);
|
||||||
|
|
||||||
const requestChannelList = useCallback((): Command => {
|
const requestChannelList = useCallback((): Command => {
|
||||||
return sideData.requestChannelList()
|
return sideData.requestChannelList();
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// ---- 派生状态 ----
|
// ---- 派生状态 ----
|
||||||
const resolvedMessages = useMemo(() => {
|
const resolvedMessages = useMemo(() => {
|
||||||
if (subAgent.subAgentView) return subAgent.subAgentView.messages
|
if (subAgent.subAgentView) return subAgent.subAgentView.messages;
|
||||||
if (scheduler.schedulerView) return scheduler.schedulerView.messages
|
if (scheduler.schedulerView) return scheduler.schedulerView.messages;
|
||||||
return messages.messages
|
return messages.messages;
|
||||||
}, [subAgent.subAgentView, scheduler.schedulerView, messages.messages])
|
}, [subAgent.subAgentView, scheduler.schedulerView, messages.messages]);
|
||||||
|
|
||||||
const isReadOnly = !sideData.isWritable
|
const isReadOnly = !sideData.isWritable;
|
||||||
|
|
||||||
// ---- 组装返回对象 ----
|
// ---- 组装返回对象 ----
|
||||||
return {
|
return {
|
||||||
@ -362,5 +377,5 @@ export function useChat(): UseChatReturn {
|
|||||||
enterSchedulerJobView: scheduler.enterSchedulerJobView,
|
enterSchedulerJobView: scheduler.enterSchedulerJobView,
|
||||||
exitSchedulerJobView: scheduler.exitSchedulerJobView,
|
exitSchedulerJobView: scheduler.exitSchedulerJobView,
|
||||||
handleStop: messages.handleStop,
|
handleStop: messages.handleStop,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol'
|
import type { WsInbound, WsOutbound, ConnectionStatus } from '../types/protocol';
|
||||||
|
|
||||||
interface UseWebSocketOptions {
|
interface UseWebSocketOptions {
|
||||||
url: string
|
url: string;
|
||||||
onMessage?: (message: WsOutbound) => void
|
onMessage?: (message: WsOutbound) => void;
|
||||||
onConnect?: () => void
|
onConnect?: () => void;
|
||||||
onDisconnect?: () => void
|
onDisconnect?: () => void;
|
||||||
onError?: (error: Event) => void
|
onError?: (error: Event) => void;
|
||||||
reconnectInterval?: number
|
reconnectInterval?: number;
|
||||||
maxReconnectAttempts?: number
|
maxReconnectAttempts?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseWebSocketReturn {
|
interface UseWebSocketReturn {
|
||||||
status: ConnectionStatus
|
status: ConnectionStatus;
|
||||||
sendMessage: (message: WsInbound) => boolean
|
sendMessage: (message: WsInbound) => boolean;
|
||||||
connect: () => void
|
connect: () => void;
|
||||||
disconnect: () => void
|
disconnect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket({
|
export function useWebSocket({
|
||||||
@ -27,113 +27,113 @@ export function useWebSocket({
|
|||||||
reconnectInterval = 3000,
|
reconnectInterval = 3000,
|
||||||
maxReconnectAttempts = 5,
|
maxReconnectAttempts = 5,
|
||||||
}: UseWebSocketOptions): UseWebSocketReturn {
|
}: UseWebSocketOptions): UseWebSocketReturn {
|
||||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected')
|
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectAttemptsRef = useRef(0)
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const isManualDisconnectRef = useRef(false)
|
const isManualDisconnectRef = useRef(false);
|
||||||
|
|
||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isManualDisconnectRef.current = false
|
isManualDisconnectRef.current = false;
|
||||||
setStatus('connecting')
|
setStatus('connecting');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url);
|
||||||
wsRef.current = ws
|
wsRef.current = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
setStatus('connected')
|
setStatus('connected');
|
||||||
reconnectAttemptsRef.current = 0
|
reconnectAttemptsRef.current = 0;
|
||||||
onConnect?.()
|
onConnect?.();
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data) as WsOutbound
|
const message = JSON.parse(event.data) as WsOutbound;
|
||||||
onMessage?.(message)
|
onMessage?.(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse message:', error)
|
console.error('Failed to parse message:', error);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
setStatus('error')
|
setStatus('error');
|
||||||
onError?.(error)
|
onError?.(error);
|
||||||
}
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
setStatus('disconnected')
|
setStatus('disconnected');
|
||||||
onDisconnect?.()
|
onDisconnect?.();
|
||||||
|
|
||||||
// Auto reconnect if not manually disconnected
|
// Auto reconnect if not manually disconnected
|
||||||
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
if (!isManualDisconnectRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||||
reconnectAttemptsRef.current += 1
|
reconnectAttemptsRef.current += 1;
|
||||||
reconnectTimerRef.current = setTimeout(() => {
|
reconnectTimerRef.current = setTimeout(() => {
|
||||||
connect()
|
connect();
|
||||||
}, reconnectInterval)
|
}, reconnectInterval);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus('error')
|
setStatus('error');
|
||||||
console.error('WebSocket connection error:', error)
|
console.error('WebSocket connection error:', error);
|
||||||
}
|
}
|
||||||
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts])
|
}, [url, onMessage, onConnect, onDisconnect, onError, reconnectInterval, maxReconnectAttempts]);
|
||||||
|
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
isManualDisconnectRef.current = true
|
isManualDisconnectRef.current = true;
|
||||||
|
|
||||||
if (reconnectTimerRef.current) {
|
if (reconnectTimerRef.current) {
|
||||||
clearTimeout(reconnectTimerRef.current)
|
clearTimeout(reconnectTimerRef.current);
|
||||||
reconnectTimerRef.current = null
|
reconnectTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wsRef.current) {
|
if (wsRef.current) {
|
||||||
wsRef.current.close()
|
wsRef.current.close();
|
||||||
wsRef.current = null
|
wsRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('disconnected')
|
setStatus('disconnected');
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const sendMessage = useCallback((message: WsInbound): boolean => {
|
const sendMessage = useCallback((message: WsInbound): boolean => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify(message))
|
wsRef.current.send(JSON.stringify(message));
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
console.warn('WebSocket is not connected')
|
console.warn('WebSocket is not connected');
|
||||||
return false
|
return false;
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Auto connect on mount, reconnect when url changes
|
// Auto connect on mount, reconnect when url changes
|
||||||
const prevUrlRef = useRef(url)
|
const prevUrlRef = useRef(url);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 首次挂载,或者 url 发生了变化,都要重连
|
// 首次挂载,或者 url 发生了变化,都要重连
|
||||||
if (prevUrlRef.current !== url) {
|
if (prevUrlRef.current !== url) {
|
||||||
// 先断开旧连接
|
// 先断开旧连接
|
||||||
disconnect()
|
disconnect();
|
||||||
prevUrlRef.current = url
|
prevUrlRef.current = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 短暂延迟确保旧 socket 已关闭
|
// 短暂延迟确保旧 socket 已关闭
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
connect()
|
connect();
|
||||||
}, 50)
|
}, 50);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer);
|
||||||
disconnect()
|
disconnect();
|
||||||
}
|
};
|
||||||
}, [url, connect, disconnect])
|
}, [url, connect, disconnect]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
PicoBot Theme — Dark (default)
|
PicoBot Theme — Dark (default)
|
||||||
@ -28,28 +28,28 @@
|
|||||||
--border-accent: rgba(0, 240, 255, 0.3);
|
--border-accent: rgba(0, 240, 255, 0.3);
|
||||||
|
|
||||||
/* Overlay — for hover / subtle surface effects */
|
/* Overlay — for hover / subtle surface effects */
|
||||||
--overlay-hover: rgba(255, 255, 255, 0.05);
|
--overlay-hover: rgba(255, 255, 255, 0.05);
|
||||||
--overlay-subtle: rgba(255, 255, 255, 0.10);
|
--overlay-subtle: rgba(255, 255, 255, 0.1);
|
||||||
--overlay-medium: rgba(255, 255, 255, 0.20);
|
--overlay-medium: rgba(255, 255, 255, 0.2);
|
||||||
--overlay-dim: rgba(0, 0, 0, 0.20);
|
--overlay-dim: rgba(0, 0, 0, 0.2);
|
||||||
--overlay-dim-strong: rgba(0, 0, 0, 0.30);
|
--overlay-dim-strong: rgba(0, 0, 0, 0.3);
|
||||||
--overlay-dim-heavy: rgba(0, 0, 0, 0.40);
|
--overlay-dim-heavy: rgba(0, 0, 0, 0.4);
|
||||||
--overlay-code: rgba(0, 0, 0, 0.30);
|
--overlay-code: rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
/* Shadows */
|
/* Shadows */
|
||||||
--shadow-glow-sm: rgba(0, 240, 255, 0.12);
|
--shadow-glow-sm: rgba(0, 240, 255, 0.12);
|
||||||
--shadow-glow: rgba(0, 240, 255, 0.20);
|
--shadow-glow: rgba(0, 240, 255, 0.2);
|
||||||
--shadow-glow-strong: rgba(0, 240, 255, 0.30);
|
--shadow-glow-strong: rgba(0, 240, 255, 0.3);
|
||||||
--shadow-glow-soft: rgba(0, 240, 255, 0.50);
|
--shadow-glow-soft: rgba(0, 240, 255, 0.5);
|
||||||
|
|
||||||
/* Focus ring */
|
/* Focus ring */
|
||||||
--focus-ring: rgba(0, 240, 255, 0.20);
|
--focus-ring: rgba(0, 240, 255, 0.2);
|
||||||
|
|
||||||
/* Selection / code highlight */
|
/* Selection / code highlight */
|
||||||
--selection-bg: rgba(0, 240, 255, 0.30);
|
--selection-bg: rgba(0, 240, 255, 0.3);
|
||||||
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
--divider-color: rgba(255, 255, 255, 0.20);
|
--divider-color: rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@ -59,51 +59,51 @@
|
|||||||
============================================ */
|
============================================ */
|
||||||
html.light {
|
html.light {
|
||||||
/* Backgrounds */
|
/* Backgrounds */
|
||||||
--bg-primary: #f5f5f7;
|
--bg-primary: #f5f5f7;
|
||||||
--bg-secondary: #ffffff;
|
--bg-secondary: #ffffff;
|
||||||
--bg-tertiary: #ebecf0;
|
--bg-tertiary: #ebecf0;
|
||||||
--bg-hover: #dfe0e5;
|
--bg-hover: #dfe0e5;
|
||||||
|
|
||||||
/* Accents — slightly darker for readability on white */
|
/* Accents — slightly darker for readability on white */
|
||||||
--accent-cyan: #008899;
|
--accent-cyan: #008899;
|
||||||
--accent-blue: #2563eb;
|
--accent-blue: #2563eb;
|
||||||
--accent-purple: #7c3aed;
|
--accent-purple: #7c3aed;
|
||||||
--accent-green: #059669;
|
--accent-green: #059669;
|
||||||
--accent-amber: #d97706;
|
--accent-amber: #d97706;
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-primary: #1a1a2e;
|
--text-primary: #1a1a2e;
|
||||||
--text-secondary: #52525b;
|
--text-secondary: #52525b;
|
||||||
--text-muted: #a1a1aa;
|
--text-muted: #a1a1aa;
|
||||||
--text-accent: var(--accent-cyan);
|
--text-accent: var(--accent-cyan);
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
--border-color: rgba(0, 0, 0, 0.08);
|
--border-color: rgba(0, 0, 0, 0.08);
|
||||||
--border-accent: rgba(0, 136, 153, 0.30);
|
--border-accent: rgba(0, 136, 153, 0.3);
|
||||||
|
|
||||||
/* Overlays */
|
/* Overlays */
|
||||||
--overlay-hover: rgba(0, 0, 0, 0.04);
|
--overlay-hover: rgba(0, 0, 0, 0.04);
|
||||||
--overlay-subtle: rgba(0, 0, 0, 0.06);
|
--overlay-subtle: rgba(0, 0, 0, 0.06);
|
||||||
--overlay-medium: rgba(0, 0, 0, 0.10);
|
--overlay-medium: rgba(0, 0, 0, 0.1);
|
||||||
--overlay-dim: rgba(0, 0, 0, 0.04);
|
--overlay-dim: rgba(0, 0, 0, 0.04);
|
||||||
--overlay-dim-strong: rgba(0, 0, 0, 0.06);
|
--overlay-dim-strong: rgba(0, 0, 0, 0.06);
|
||||||
--overlay-dim-heavy: rgba(0, 0, 0, 0.08);
|
--overlay-dim-heavy: rgba(0, 0, 0, 0.08);
|
||||||
--overlay-code: rgba(0, 0, 0, 0.06);
|
--overlay-code: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
/* Shadows — softer */
|
/* Shadows — softer */
|
||||||
--shadow-glow-sm: rgba(0, 136, 153, 0.10);
|
--shadow-glow-sm: rgba(0, 136, 153, 0.1);
|
||||||
--shadow-glow: rgba(0, 136, 153, 0.15);
|
--shadow-glow: rgba(0, 136, 153, 0.15);
|
||||||
--shadow-glow-strong: rgba(0, 136, 153, 0.22);
|
--shadow-glow-strong: rgba(0, 136, 153, 0.22);
|
||||||
--shadow-glow-soft: rgba(0, 136, 153, 0.35);
|
--shadow-glow-soft: rgba(0, 136, 153, 0.35);
|
||||||
|
|
||||||
/* Focus ring */
|
/* Focus ring */
|
||||||
--focus-ring: rgba(0, 136, 153, 0.20);
|
--focus-ring: rgba(0, 136, 153, 0.2);
|
||||||
|
|
||||||
/* Selection / code highlight */
|
/* Selection / code highlight */
|
||||||
--selection-bg: rgba(0, 136, 153, 0.20);
|
--selection-bg: rgba(0, 136, 153, 0.2);
|
||||||
|
|
||||||
/* Divider */
|
/* Divider */
|
||||||
--divider-color: rgba(0, 0, 0, 0.10);
|
--divider-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@ -114,11 +114,12 @@ html.light {
|
|||||||
html.theme-transitioning *,
|
html.theme-transitioning *,
|
||||||
html.theme-transitioning *::before,
|
html.theme-transitioning *::before,
|
||||||
html.theme-transitioning *::after {
|
html.theme-transitioning *::after {
|
||||||
transition: background-color 0.3s ease,
|
transition:
|
||||||
color 0.3s ease,
|
background-color 0.3s ease,
|
||||||
border-color 0.3s ease,
|
color 0.3s ease,
|
||||||
box-shadow 0.3s ease,
|
border-color 0.3s ease,
|
||||||
text-shadow 0.3s ease !important;
|
box-shadow 0.3s ease,
|
||||||
|
text-shadow 0.3s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@ -182,9 +183,10 @@ body {
|
|||||||
Glowing text effect
|
Glowing text effect
|
||||||
============================================ */
|
============================================ */
|
||||||
.glow-text {
|
.glow-text {
|
||||||
text-shadow: 0 0 10px var(--shadow-glow-soft),
|
text-shadow:
|
||||||
0 0 20px var(--shadow-glow-strong),
|
0 0 10px var(--shadow-glow-soft),
|
||||||
0 0 30px var(--shadow-glow-sm);
|
0 0 20px var(--shadow-glow-strong),
|
||||||
|
0 0 30px var(--shadow-glow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
@ -215,15 +217,18 @@ body {
|
|||||||
Animations
|
Animations
|
||||||
============================================ */
|
============================================ */
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% {
|
0%,
|
||||||
box-shadow: 0 0 5px var(--accent-cyan),
|
100% {
|
||||||
0 0 10px var(--accent-cyan),
|
box-shadow:
|
||||||
0 0 20px var(--accent-cyan);
|
0 0 5px var(--accent-cyan),
|
||||||
|
0 0 10px var(--accent-cyan),
|
||||||
|
0 0 20px var(--accent-cyan);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 10px var(--accent-cyan),
|
box-shadow:
|
||||||
0 0 20px var(--accent-cyan),
|
0 0 10px var(--accent-cyan),
|
||||||
0 0 40px var(--accent-cyan);
|
0 0 20px var(--accent-cyan),
|
||||||
|
0 0 40px var(--accent-cyan);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,24 +244,48 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes typing-dot {
|
@keyframes typing-dot {
|
||||||
0%, 60%, 100% { transform: translateY(0); }
|
0%,
|
||||||
30% { transform: translateY(-4px); }
|
60%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scale-in {
|
@keyframes scale-in {
|
||||||
0% { opacity: 0; transform: scale(0.5); }
|
0% {
|
||||||
50% { transform: scale(1.1); }
|
opacity: 0;
|
||||||
100% { opacity: 1; transform: scale(1); }
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-slide-in { animation: slide-in 0.3s ease-out; }
|
.animate-slide-in {
|
||||||
.animate-fade-in { animation: fade-in 0.2s ease-out; }
|
animation: slide-in 0.3s ease-out;
|
||||||
.animate-scale-in { animation: scale-in 0.3s ease-out; }
|
}
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.animate-scale-in {
|
||||||
|
animation: scale-in 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TodoPanel animations
|
TodoPanel animations
|
||||||
@ -286,7 +315,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes todo-ring-pulse {
|
@keyframes todo-ring-pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
|
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@ -295,7 +325,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes todo-highlight-pulse {
|
@keyframes todo-highlight-pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@ -303,9 +334,15 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
.animate-todo-card-in {
|
||||||
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; }
|
animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; }
|
}
|
||||||
|
.animate-todo-item-in {
|
||||||
|
animation: todo-item-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-todo-ring-pulse {
|
||||||
|
animation: todo-ring-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* 待办点击高亮效果 */
|
/* 待办点击高亮效果 */
|
||||||
.todo-highlight {
|
.todo-highlight {
|
||||||
@ -316,7 +353,9 @@ body {
|
|||||||
/* 分组折叠内容展开/收起 */
|
/* 分组折叠内容展开/收起 */
|
||||||
.todo-group-body {
|
.todo-group-body {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.25s ease, opacity 0.2s ease;
|
transition:
|
||||||
|
max-height 0.25s ease,
|
||||||
|
opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
.todo-group-body-open {
|
.todo-group-body-open {
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
@ -328,18 +367,30 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes thinking-reveal {
|
@keyframes thinking-reveal {
|
||||||
from { max-height: 0; opacity: 0; }
|
from {
|
||||||
to { max-height: 300px; opacity: 1; }
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
max-height: 300px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-thinking-reveal { animation: thinking-reveal 0.25s ease-out; }
|
.animate-thinking-reveal {
|
||||||
|
animation: thinking-reveal 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
.typing-indicator span {
|
.typing-indicator span {
|
||||||
animation: typing-dot 1.4s infinite;
|
animation: typing-dot 1.4s infinite;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
.typing-indicator span:nth-child(2) {
|
||||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
.typing-indicator span:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Markdown list — kill extra spacing from loose-list <p> wrappers
|
Markdown list — kill extra spacing from loose-list <p> wrappers
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import React from 'react'
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App'
|
import App from './App';
|
||||||
import './index.css'
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|||||||
@ -5,294 +5,294 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface WsInboundMessage {
|
export interface WsInboundMessage {
|
||||||
type: 'message'
|
type: 'message';
|
||||||
content: string
|
content: string;
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[];
|
||||||
channel?: string
|
channel?: string;
|
||||||
chat_id?: string
|
chat_id?: string;
|
||||||
sender_id?: string
|
sender_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsInboundCommand {
|
export interface WsInboundCommand {
|
||||||
type: 'command'
|
type: 'command';
|
||||||
payload: string
|
payload: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsInboundPing {
|
export interface WsInboundPing {
|
||||||
type: 'ping'
|
type: 'ping';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing
|
export type WsInbound = WsInboundMessage | WsInboundCommand | WsInboundPing;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Outbound Messages (Server -> Client)
|
// Outbound Messages (Server -> Client)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface Attachment {
|
export interface Attachment {
|
||||||
path: string
|
path: string;
|
||||||
media_type: string
|
media_type: string;
|
||||||
mime_type?: string
|
mime_type?: string;
|
||||||
content_base64?: string
|
content_base64?: string;
|
||||||
file_name?: string
|
file_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssistantResponse {
|
export interface AssistantResponse {
|
||||||
type: 'assistant_response'
|
type: 'assistant_response';
|
||||||
id: string
|
id: string;
|
||||||
content: string
|
content: string;
|
||||||
role: string
|
role: string;
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[];
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
reasoning_content?: string
|
reasoning_content?: string;
|
||||||
user_message_id?: string
|
user_message_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
type: 'tool_call'
|
type: 'tool_call';
|
||||||
id: string
|
id: string;
|
||||||
tool_call_id: string
|
tool_call_id: string;
|
||||||
tool_name: string
|
tool_name: string;
|
||||||
arguments: unknown
|
arguments: unknown;
|
||||||
content: string
|
content: string;
|
||||||
role: string
|
role: string;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
reasoning_content?: string
|
reasoning_content?: string;
|
||||||
user_message_id?: string
|
user_message_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
type: 'tool_result'
|
type: 'tool_result';
|
||||||
id: string
|
id: string;
|
||||||
tool_call_id: string
|
tool_call_id: string;
|
||||||
tool_name: string
|
tool_name: string;
|
||||||
content: string
|
content: string;
|
||||||
role: string
|
role: string;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
duration_ms?: number
|
duration_ms?: number;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolPending {
|
export interface ToolPending {
|
||||||
type: 'tool_pending'
|
type: 'tool_pending';
|
||||||
id: string
|
id: string;
|
||||||
tool_call_id: string
|
tool_call_id: string;
|
||||||
tool_name: string
|
tool_name: string;
|
||||||
content: string
|
content: string;
|
||||||
role: string
|
role: string;
|
||||||
resume_hint: string
|
resume_hint: string;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WsError {
|
export interface WsError {
|
||||||
type: 'error'
|
type: 'error';
|
||||||
code: string
|
code: string;
|
||||||
message: string
|
message: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskStarted {
|
export interface TaskStarted {
|
||||||
type: 'task_started'
|
type: 'task_started';
|
||||||
task_id: string
|
task_id: string;
|
||||||
description: string
|
description: string;
|
||||||
subagent_type: string
|
subagent_type: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
parent_task_id?: string
|
parent_task_id?: string;
|
||||||
tool_call_id?: string
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionEstablished {
|
export interface SessionEstablished {
|
||||||
type: 'session_established'
|
type: 'session_established';
|
||||||
session_id: string
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionCreated {
|
export interface SessionCreated {
|
||||||
type: 'session_created'
|
type: 'session_created';
|
||||||
session_id: string
|
session_id: string;
|
||||||
title: string
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
session_id: string
|
session_id: string;
|
||||||
title: string
|
title: string;
|
||||||
channel_name: string
|
channel_name: string;
|
||||||
chat_id: string
|
chat_id: string;
|
||||||
message_count: number
|
message_count: number;
|
||||||
last_active_at: number
|
last_active_at: number;
|
||||||
archived_at?: number
|
archived_at?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionList {
|
export interface SessionList {
|
||||||
type: 'session_list'
|
type: 'session_list';
|
||||||
sessions: SessionSummary[]
|
sessions: SessionSummary[];
|
||||||
current_session_id?: string
|
current_session_id?: string;
|
||||||
channel_name?: string // 新增:标识所属通道
|
channel_name?: string; // 新增:标识所属通道
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionLoaded {
|
export interface SessionLoaded {
|
||||||
type: 'session_loaded'
|
type: 'session_loaded';
|
||||||
session_id: string
|
session_id: string;
|
||||||
title: string
|
title: string;
|
||||||
message_count: number
|
message_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionSaved {
|
export interface SessionSaved {
|
||||||
type: 'session_saved'
|
type: 'session_saved';
|
||||||
session_id: string
|
session_id: string;
|
||||||
filepath: string
|
filepath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopicSummary {
|
export interface TopicSummary {
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
session_id: string
|
session_id: string;
|
||||||
title: string
|
title: string;
|
||||||
description?: string
|
description?: string;
|
||||||
message_count: number
|
message_count: number;
|
||||||
created_at: number
|
created_at: number;
|
||||||
last_active_at: number
|
last_active_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopicList {
|
export interface TopicList {
|
||||||
type: 'topic_list'
|
type: 'topic_list';
|
||||||
topics: TopicSummary[]
|
topics: TopicSummary[];
|
||||||
session_id: string
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TopicRenamed {
|
export interface TopicRenamed {
|
||||||
type: 'topic_renamed'
|
type: 'topic_renamed';
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
title: string
|
title: string;
|
||||||
topics: TopicSummary[]
|
topics: TopicSummary[];
|
||||||
session_id: string
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
description?: string
|
description?: string;
|
||||||
isWritable: boolean
|
isWritable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelList {
|
export interface ChannelList {
|
||||||
type: 'channel_list'
|
type: 'channel_list';
|
||||||
channels: Channel[]
|
channels: Channel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pong {
|
export interface Pong {
|
||||||
type: 'pong'
|
type: 'pong';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemorySummary {
|
export interface MemorySummary {
|
||||||
id: string
|
id: string;
|
||||||
namespace: string
|
namespace: string;
|
||||||
memory_key: string
|
memory_key: string;
|
||||||
content: string
|
content: string;
|
||||||
created_at: number
|
created_at: number;
|
||||||
updated_at: number
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoryList {
|
export interface MemoryList {
|
||||||
type: 'memory_list'
|
type: 'memory_list';
|
||||||
memories: MemorySummary[]
|
memories: MemorySummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillSummary {
|
export interface SkillSummary {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
source: string
|
source: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillList {
|
export interface SkillList {
|
||||||
type: 'skill_list'
|
type: 'skill_list';
|
||||||
skills: SkillSummary[]
|
skills: SkillSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoItemSummary {
|
export interface TodoItemSummary {
|
||||||
id: string
|
id: string;
|
||||||
content: string
|
content: string;
|
||||||
status: string
|
status: string;
|
||||||
priority: string
|
priority: string;
|
||||||
created_at: number
|
created_at: number;
|
||||||
updated_at: number
|
updated_at: number;
|
||||||
created_by_message_id?: string
|
created_by_message_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoList {
|
export interface TodoList {
|
||||||
type: 'todo_list'
|
type: 'todo_list';
|
||||||
todos: TodoItemSummary[]
|
todos: TodoItemSummary[];
|
||||||
scope_key: string
|
scope_key: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerJobSessionLookup {
|
export interface SchedulerJobSessionLookup {
|
||||||
channel: string
|
channel: string;
|
||||||
chat_id: string
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerJobSummary {
|
export interface SchedulerJobSummary {
|
||||||
id: string
|
id: string;
|
||||||
kind: string
|
kind: string;
|
||||||
schedule: unknown
|
schedule: unknown;
|
||||||
enabled: boolean
|
enabled: boolean;
|
||||||
state: string
|
state: string;
|
||||||
last_status?: string
|
last_status?: string;
|
||||||
last_error?: string
|
last_error?: string;
|
||||||
run_count: number
|
run_count: number;
|
||||||
max_runs?: number
|
max_runs?: number;
|
||||||
last_fired_at?: number
|
last_fired_at?: number;
|
||||||
next_fire_at?: number
|
next_fire_at?: number;
|
||||||
created_at: number
|
created_at: number;
|
||||||
session_lookup?: SchedulerJobSessionLookup
|
session_lookup?: SchedulerJobSessionLookup;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchedulerJobList {
|
export interface SchedulerJobList {
|
||||||
type: 'scheduler_job_list'
|
type: 'scheduler_job_list';
|
||||||
jobs: SchedulerJobSummary[]
|
jobs: SchedulerJobSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskMessagesLoaded {
|
export interface TaskMessagesLoaded {
|
||||||
type: 'task_messages_loaded'
|
type: 'task_messages_loaded';
|
||||||
task_id: string
|
task_id: string;
|
||||||
description: string
|
description: string;
|
||||||
subagent_type: string
|
subagent_type: string;
|
||||||
status: string
|
status: string;
|
||||||
summary?: string
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionCancelled {
|
export interface ExecutionCancelled {
|
||||||
type: 'execution_cancelled'
|
type: 'execution_cancelled';
|
||||||
message: string
|
message: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamDelta {
|
export interface StreamDelta {
|
||||||
type: 'stream_delta'
|
type: 'stream_delta';
|
||||||
id: string
|
id: string;
|
||||||
delta: string
|
delta: string;
|
||||||
reasoning_delta?: string
|
reasoning_delta?: string;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
user_message_id?: string
|
user_message_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamEnd {
|
export interface StreamEnd {
|
||||||
type: 'stream_end'
|
type: 'stream_end';
|
||||||
id: string
|
id: string;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecutionCompleted {
|
export interface ExecutionCompleted {
|
||||||
type: 'execution_completed'
|
type: 'execution_completed';
|
||||||
topic_id?: string
|
topic_id?: string;
|
||||||
timestamp?: number
|
timestamp?: number;
|
||||||
subagent_task_id?: string
|
subagent_task_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WsOutbound =
|
export type WsOutbound =
|
||||||
@ -319,127 +319,127 @@ export type WsOutbound =
|
|||||||
| SkillList
|
| SkillList
|
||||||
| TodoList
|
| TodoList
|
||||||
| ExecutionCancelled
|
| ExecutionCancelled
|
||||||
| Pong
|
| Pong;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Commands
|
// Commands
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface CreateSessionCommand {
|
export interface CreateSessionCommand {
|
||||||
type: 'create_session'
|
type: 'create_session';
|
||||||
title?: string
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSessionsCommand {
|
export interface ListSessionsCommand {
|
||||||
type: 'list_sessions'
|
type: 'list_sessions';
|
||||||
include_archived: boolean
|
include_archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwitchTopicCommand {
|
export interface SwitchTopicCommand {
|
||||||
type: 'switch_topic'
|
type: 'switch_topic';
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveTopicCommand {
|
export interface SaveTopicCommand {
|
||||||
type: 'save_topic'
|
type: 'save_topic';
|
||||||
filepath?: string
|
filepath?: string;
|
||||||
include_subagents: boolean
|
include_subagents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveSessionCommand {
|
export interface SaveSessionCommand {
|
||||||
type: 'save_session'
|
type: 'save_session';
|
||||||
filepath?: string
|
filepath?: string;
|
||||||
include_all: boolean
|
include_all: boolean;
|
||||||
include_subagents: boolean
|
include_subagents: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadTopicCommand {
|
export interface LoadTopicCommand {
|
||||||
type: 'load_topic'
|
type: 'load_topic';
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetCurrentSessionCommand {
|
export interface GetCurrentSessionCommand {
|
||||||
type: 'get_current_session'
|
type: 'get_current_session';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HelpCommand {
|
export interface HelpCommand {
|
||||||
type: 'help'
|
type: 'help';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListChannelsCommand {
|
export interface ListChannelsCommand {
|
||||||
type: 'list_channels'
|
type: 'list_channels';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSessionsByChannelCommand {
|
export interface ListSessionsByChannelCommand {
|
||||||
type: 'list_sessions_by_channel'
|
type: 'list_sessions_by_channel';
|
||||||
channel_name: string
|
channel_name: string;
|
||||||
include_archived: boolean
|
include_archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListTopicsCommand {
|
export interface ListTopicsCommand {
|
||||||
type: 'list_topics'
|
type: 'list_topics';
|
||||||
session_id: string
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadTaskMessagesCommand {
|
export interface LoadTaskMessagesCommand {
|
||||||
type: 'load_task_messages'
|
type: 'load_task_messages';
|
||||||
task_id: string
|
task_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSchedulerJobsCommand {
|
export interface ListSchedulerJobsCommand {
|
||||||
type: 'list_scheduler_jobs'
|
type: 'list_scheduler_jobs';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoadChatMessagesCommand {
|
export interface LoadChatMessagesCommand {
|
||||||
type: 'load_chat_messages'
|
type: 'load_chat_messages';
|
||||||
channel: string
|
channel: string;
|
||||||
chat_id: string
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTopicCommand {
|
export interface DeleteTopicCommand {
|
||||||
type: 'delete_topic'
|
type: 'delete_topic';
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenameTopicCommand {
|
export interface RenameTopicCommand {
|
||||||
type: 'rename_topic'
|
type: 'rename_topic';
|
||||||
topic_id: string
|
topic_id: string;
|
||||||
title: string
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StopExecutionCommand {
|
export interface StopExecutionCommand {
|
||||||
type: 'stop_execution'
|
type: 'stop_execution';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListMemoriesCommand {
|
export interface ListMemoriesCommand {
|
||||||
type: 'list_memories'
|
type: 'list_memories';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMemoryCommand {
|
export interface CreateMemoryCommand {
|
||||||
type: 'create_memory'
|
type: 'create_memory';
|
||||||
namespace: string
|
namespace: string;
|
||||||
key: string
|
key: string;
|
||||||
content: string
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemoryCommand {
|
export interface UpdateMemoryCommand {
|
||||||
type: 'update_memory'
|
type: 'update_memory';
|
||||||
id: string
|
id: string;
|
||||||
content: string
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteMemoryCommand {
|
export interface DeleteMemoryCommand {
|
||||||
type: 'delete_memory'
|
type: 'delete_memory';
|
||||||
id: string
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSkillsCommand {
|
export interface ListSkillsCommand {
|
||||||
type: 'list_skills'
|
type: 'list_skills';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListTodosCommand {
|
export interface ListTodosCommand {
|
||||||
type: 'list_todos'
|
type: 'list_todos';
|
||||||
task_id?: string
|
task_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Command =
|
export type Command =
|
||||||
@ -465,57 +465,57 @@ export type Command =
|
|||||||
| UpdateMemoryCommand
|
| UpdateMemoryCommand
|
||||||
| DeleteMemoryCommand
|
| DeleteMemoryCommand
|
||||||
| ListSkillsCommand
|
| ListSkillsCommand
|
||||||
| ListTodosCommand
|
| ListTodosCommand;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// UI Types
|
// UI Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
id: string
|
id: string;
|
||||||
role: 'user' | 'assistant' | 'tool'
|
role: 'user' | 'assistant' | 'tool';
|
||||||
content: string
|
content: string;
|
||||||
timestamp: number
|
timestamp: number;
|
||||||
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool'
|
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' | 'merged_tool';
|
||||||
toolName?: string
|
toolName?: string;
|
||||||
toolCallId?: string
|
toolCallId?: string;
|
||||||
arguments?: unknown
|
arguments?: unknown;
|
||||||
attachments?: Attachment[]
|
attachments?: Attachment[];
|
||||||
status?: 'calling' | 'result' | 'pending'
|
status?: 'calling' | 'result' | 'pending';
|
||||||
resultContent?: string
|
resultContent?: string;
|
||||||
callContent?: string
|
callContent?: string;
|
||||||
/** 路由字段:标识消息属于哪个子智能体会话(与后端 subagent_task_id 一致) */
|
/** 路由字段:标识消息属于哪个子智能体会话(与后端 subagent_task_id 一致) */
|
||||||
subagentTaskId?: string
|
subagentTaskId?: string;
|
||||||
/** 导航字段:仅 task 工具卡片使用,由 task_started 事件设置,指向新创建的子/孙智能体 task_id */
|
/** 导航字段:仅 task 工具卡片使用,由 task_started 事件设置,指向新创建的子/孙智能体 task_id */
|
||||||
navigateToTaskId?: string
|
navigateToTaskId?: string;
|
||||||
durationMs?: number
|
durationMs?: number;
|
||||||
reasoningContent?: string
|
reasoningContent?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** task 工具返回的 JSON 结构 */
|
/** task 工具返回的 JSON 结构 */
|
||||||
export interface TaskToolResult {
|
export interface TaskToolResult {
|
||||||
status: 'success' | 'failed' | 'timeout'
|
status: 'success' | 'failed' | 'timeout';
|
||||||
summary: string
|
summary: string;
|
||||||
output: string
|
output: string;
|
||||||
task_id: string
|
task_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Topic {
|
export interface Topic {
|
||||||
id: string
|
id: string;
|
||||||
session_id: string
|
session_id: string;
|
||||||
title: string
|
title: string;
|
||||||
description?: string
|
description?: string;
|
||||||
message_count: number
|
message_count: number;
|
||||||
created_at: number
|
created_at: number;
|
||||||
updated_at: number
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string;
|
||||||
channel_name: string
|
channel_name: string;
|
||||||
title?: string
|
title?: string;
|
||||||
created_at: number
|
created_at: number;
|
||||||
updated_at: number
|
updated_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error'
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||||
|
|||||||
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