console: React 生产级重写(左列表 + 中对话 + 右详情)
旧单页 console.html 换成 Vite + React + TypeScript 单文件构建产物 (309525 字节,sha 90345ec8…,重建字节一致),源码在 cloud/console-react/,改页面只改 src 再 npm run build:console, 产物不手改。布局:左侧列表(模块导航 + 任务列表)、中间标准对话 (实时链路时间线 + 继续会话聊天框,共用 Chat 组件)、右侧详情 (元数据、操作、评审、审批、协调子任务、子代理、线程)。 API 与后端语义未改,key 只存浏览器 localStorage。 6 个 console 标记单测随实现改名更新(意图不变:壳确实调了 这些接口、turn 信息仍在详情里、旧 goal 输入仍不存在); P1 标记单测未动且通过。
This commit is contained in:
1 parent
3f4e849a93
commit
ebc5624893
57 files changed
+4789
-1746
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Card({ id, className, title, children }: { id?: string; className?: string; title?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section id={id} className={"card" + (className ? " " + className : "")}>
|
||||
{title ? <h2>{title}</h2> : null}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export function Bar({ children }: { children: ReactNode }) {
|
||||
return <div className="bar">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type View = "task" | "projects" | "env" | "workers" | "skills" | "automations" | "triage" | "admin" | "key";
|
||||
|
||||
const ITEMS: Array<[View, string]> = [
|
||||
["task", "任务"], ["projects", "项目"], ["env", "环境"], ["workers", "Workers"],
|
||||
["skills", "技能与 MCP"], ["automations", "自动化"], ["triage", "分诊"],
|
||||
["admin", "管理区"], ["key", "API Key"],
|
||||
];
|
||||
|
||||
export default function LeftNav({ view, onSelect }: { view: View; onSelect: (v: View) => void }) {
|
||||
return (
|
||||
<nav className="left-nav" aria-label="模块导航">
|
||||
{ITEMS.map(([v, label]) => (
|
||||
<button key={v} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>{label}</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage } from "./mapTrace";
|
||||
|
||||
export function useAutoFollow(dep: unknown) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [following, setFollowing] = useState(true);
|
||||
const followingRef = useRef(true);
|
||||
const onScroll = () => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
followingRef.current = nearBottom;
|
||||
setFollowing(nearBottom);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (followingRef.current && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [dep]);
|
||||
return { ref, following, onScroll };
|
||||
}
|
||||
|
||||
export function ChatBubble({ msg }: { msg: ChatMessage }) {
|
||||
if (msg.role === "status") {
|
||||
return <div className="chat-status">{msg.text}</div>;
|
||||
}
|
||||
return (
|
||||
<div className={"chat-row " + msg.role}>
|
||||
<div className={"chat-bubble " + msg.role}>
|
||||
{msg.label ? <div className="chat-label">{msg.label}</div> : null}
|
||||
<div className="chat-text">{msg.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatList({ messages, empty }: { messages: ChatMessage[]; empty?: string }) {
|
||||
const { ref, following, onScroll } = useAutoFollow(messages.length);
|
||||
return (
|
||||
<div className="chat-list" ref={ref} onScroll={onScroll}>
|
||||
{messages.length === 0 ? <div className="muted">{empty || "(暂无消息)"}</div> : messages.map((m) => <ChatBubble key={m.id} msg={m} />)}
|
||||
{!following && messages.length > 0 ? (
|
||||
<button className="chat-follow" onClick={() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }}>回到最新 ↓</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, placeholder }: {
|
||||
onSend: (text: string) => void; disabled?: boolean; placeholder?: string;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const send = () => {
|
||||
const v = text.trim();
|
||||
if (!v || disabled) return;
|
||||
onSend(v);
|
||||
setText("");
|
||||
};
|
||||
return (
|
||||
<div className="chat-input-bar">
|
||||
<textarea
|
||||
aria-label="会话输入"
|
||||
className="chat-input"
|
||||
rows={1}
|
||||
placeholder={placeholder || "输入消息,回车发送,Shift+回车换行"}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
|
||||
/>
|
||||
<button className="primary chat-send" disabled={disabled || !text.trim()} onClick={send}>发送</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TraceEntry } from "../../api/types";
|
||||
import { traceLineText } from "../../api/client";
|
||||
|
||||
export type ChatRole = "user" | "assistant" | "tool" | "status";
|
||||
export interface ChatMessage { id: string; role: ChatRole; text: string; label?: string }
|
||||
|
||||
// Mapping is by live trace kind ONLY. Trace entries carry no timestamp
|
||||
// and no sender role in the live file, so no role is invented from
|
||||
// nothing: message entries are the agent's replies, exec/file/tool/call
|
||||
// are tool activity, and plan/reasoning/agent-start/agent-done are
|
||||
// status timeline items. The task prompt itself is the user message
|
||||
// (it comes from the task record, not from a trace entry).
|
||||
export function traceEntryToChatMessage(e: TraceEntry, i: number): ChatMessage {
|
||||
const id = e.id || "trace-" + i;
|
||||
if (e.kind === "message") {
|
||||
return { id, role: "assistant", text: (e.phase ? "[" + e.phase + "] " : "") + (e.text || ""), label: "助手" };
|
||||
}
|
||||
if (e.kind === "exec" || e.kind === "file" || e.kind === "tool" || e.kind === "call") {
|
||||
const label = e.kind === "exec" ? "命令" : e.kind === "file" ? "文件" : e.kind === "tool" ? "工具" : "调用";
|
||||
return { id, role: "tool", text: traceLineText(e), label };
|
||||
}
|
||||
if (e.kind === "plan") return { id, role: "status", text: e.text || "" };
|
||||
if (e.kind === "reasoning") return { id, role: "status", text: "思考:" + (e.summary || "") };
|
||||
if (e.kind === "agent-start") return { id, role: "status", text: "子代理开始:" + (e.name || "") + (e.prompt ? "(" + e.prompt + ")" : "") };
|
||||
if (e.kind === "agent-done") return { id, role: "status", text: "子代理结束:" + (e.name || "") + " " + (e.status || "") };
|
||||
return { id, role: "status", text: traceLineText(e) };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Select({ value, onChange, children, disabled, ariaLabel }: {
|
||||
value: string; onChange: (v: string) => void; children: ReactNode; disabled?: boolean; ariaLabel?: string;
|
||||
}) {
|
||||
return <select aria-label={ariaLabel} value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>{children}</select>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// One shared list style for every former <br>-joined list in the live file.
|
||||
export function DataList({ items, empty }: { items: ReactNode[]; empty?: string }) {
|
||||
if (!items.length) return <div className="muted">{empty || "(暂无)"}</div>;
|
||||
return <ul className="data-list">{items.map((it, i) => <li key={i}>{it}</li>)}</ul>;
|
||||
}
|
||||
export function DataListRaw({ children }: { children: ReactNode }) {
|
||||
return <ul className="data-list">{children}</ul>;
|
||||
}
|
||||
export function StatusPill({ status }: { status?: string }) {
|
||||
return <span className={"st-" + (status || "")}>{status || ""}</span>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function ErrorNote({ children }: { children: ReactNode }) {
|
||||
if (!children) return null;
|
||||
return <span className="err">{children}</span>;
|
||||
}
|
||||
export function OkNote({ children }: { children: ReactNode }) {
|
||||
if (!children) return null;
|
||||
return <span className="ok">{children}</span>;
|
||||
}
|
||||
export function EmptyState({ children }: { children: ReactNode }) {
|
||||
return <div className="muted">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Barrel: sections import shared components from here. The components
|
||||
// themselves live in one file each (Card, lists, notes, controls).
|
||||
export { Card, Bar } from "./Card";
|
||||
export { DataList, DataListRaw, StatusPill } from "./lists";
|
||||
export { ErrorNote, OkNote, EmptyState } from "./notes";
|
||||
export { Select } from "./controls";
|
||||
Reference in new issue
Block a user