样式修改

This commit is contained in:
董宏凯 committed 2026-05-25 22:39:50 +08:00
1 parent 114ee8f6ac
commit e3d8e534f6
6 files changed
+739 -48

No files matched your search

@@ -21,9 +21,80 @@ export interface IntentInitResponse {
thread_id: string;
}
/**
* Structured diagnostic payload surfaced by the backend whenever an intent
* route raises (init) or yields a final `status: "error"` frame (stream).
* Same shape across `init_intent` / `stream_intent`; renderer keys off `code`
* for quick visual triage, but also shows `hint` + `trace_tail` as a panel
* so deployment-time issues (config.yaml unreachable LLM, missing builtin
* agent dirs, loopback going through proxy) are debuggable from the UI alone
* without tail-ing backend logs.
*/
export interface IntentErrorDetail {
/** Stable short category — see backend `_diagnose_exception`. */
code: string;
/** One-line human-readable description (already includes exception type). */
message: string;
exception_type: string;
exception_repr: string;
/** Root of the `__cause__` chain when distinct from the outer exception. */
root_type?: string | null;
root_repr?: string | null;
/** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */
hint?: string | null;
/** Last ~12 lines of the traceback for in-UI inspection. */
trace_tail?: string[];
/** Caller-supplied label (e.g. "intent_init"). */
context?: string | null;
}
/**
* Thrown by `initIntent` when the HTTP call fails. Carries the parsed
* `IntentErrorDetail` (if the backend emitted one) so the page can render
* a rich diagnostic panel instead of a one-line message.
*/
export class IntentInitError extends Error {
/** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */
readonly status: number;
/** Structured detail when the backend emitted `_diagnose_exception` output. */
readonly detail: IntentErrorDetail | null;
/** Raw body text when JSON parse failed (kept for completeness). */
readonly rawBody: string;
constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) {
super(detail?.message || `init intent failed (${status}): ${rawBody}`);
this.name = "IntentInitError";
this.status = status;
this.detail = detail;
this.rawBody = rawBody;
}
}
function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
if (!value || typeof value !== "object") return null;
const obj = value as Record<string, unknown>;
if (typeof obj.code !== "string" || typeof obj.message !== "string") return null;
return {
code: obj.code,
message: obj.message,
exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "",
exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "",
root_type: typeof obj.root_type === "string" ? obj.root_type : null,
root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null,
hint: typeof obj.hint === "string" ? obj.hint : null,
trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [],
context: typeof obj.context === "string" ? obj.context : null,
};
}
/**
* Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
*
* On failure throws `IntentInitError` whose `.detail` carries the backend's
* `_diagnose_exception` payload — the page should surface `detail.hint` /
* `detail.trace_tail` to help diagnose intranet deployment issues without
* shell access to the server.
*/
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
@@ -32,8 +103,18 @@ export async function initIntent(model?: string): Promise<IntentInitResponse> {
body: JSON.stringify({ model }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`);
const raw = await res.text().catch(() => "");
let detail: IntentErrorDetail | null = null;
try {
const parsed = JSON.parse(raw);
// FastAPI HTTPException(detail=dict) → { "detail": {...} }
if (parsed && typeof parsed === "object" && parsed !== null) {
detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed);
}
} catch {
// body wasn't JSON — fall through with raw text only.
}
throw new IntentInitError(res.status, detail, raw);
}
return (await res.json()) as IntentInitResponse;
}
@@ -89,6 +170,12 @@ export interface StreamIntentResult {
* cards + custom input instead of treating the turn as plain text only.
*/
clarification: ClarificationPayload | null;
/**
* Populated only when status === "error" and the backend emitted a
* structured diagnostic (new in `_diagnose_exception`). UI uses this to show
* the same rich panel as init failures.
*/
errorDetail: IntentErrorDetail | null;
}
/**
@@ -106,6 +193,7 @@ function isFinalIntentFrame(value: unknown): value is {
options?: unknown[];
allow_custom?: boolean;
allow_multiple?: boolean;
error_detail?: unknown;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
@@ -192,6 +280,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
content: "",
summary: null,
clarification: null,
errorDetail: null,
};
while (true) {
@@ -231,6 +320,9 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
}
if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
}
if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
@@ -57,7 +57,9 @@ import {
} from "@/roundtable-planning/api/multi-agent";
import {
initIntent,
IntentInitError,
streamIntent,
type IntentErrorDetail,
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
@@ -504,6 +506,8 @@ export default function RoundtablePlanningPage() {
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
// 后端 _diagnose_exception 输出的结构化诊断,有就额外渲染 hint + trace_tail 面板。
const [intentInitErrorDetail, setIntentInitErrorDetail] = useState<IntentErrorDetail | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
@@ -1225,6 +1229,7 @@ export default function RoundtablePlanningPage() {
const myGen = ++step1InitGenRef.current;
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
@@ -1256,7 +1261,14 @@ export default function RoundtablePlanningPage() {
} catch (err) {
if (step1InitGenRef.current !== myGen) return;
console.error("init intent failed:", err);
setIntentInitError((err as Error).message);
if (err instanceof IntentInitError) {
// 优先使用后端的可读 message;若 detail 在,渲染诊断面板。
setIntentInitError(err.detail?.message || err.message);
setIntentInitErrorDetail(err.detail);
} else {
setIntentInitError((err as Error).message);
setIntentInitErrorDetail(null);
}
}
})();
}, []);
@@ -1420,6 +1432,7 @@ export default function RoundtablePlanningPage() {
setIntentThreadId(draft.step1.intentThreadId);
setIntentReady(draft.step1.intentReady);
setIntentInitError(null);
setIntentInitErrorDetail(null);
setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages);
setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries);
step1InitRef.current = true;
@@ -1629,6 +1642,15 @@ export default function RoundtablePlanningPage() {
return next;
});
} else if (res.status === "error") {
// 同时把诊断信息写到顶部面板:toast 一闪而过,面板能稳定展示 hint + trace_tail
// 方便用户截图发给后端排查。
if (res.errorDetail) {
setIntentInitError(res.errorDetail.message || res.content);
setIntentInitErrorDetail(res.errorDetail);
} else {
setIntentInitError(res.content);
setIntentInitErrorDetail(null);
}
triggerToast(`❌ 智能体调用失败: ${res.content}`);
}
} catch (err) {
@@ -1955,41 +1977,6 @@ export default function RoundtablePlanningPage() {
{/* Right Actions */}
<div className="flex items-center gap-2">
{/* 全局模型选择器
─────────────────────────────────────────────
影响 Step 1 / 推荐 / Step 2 init / 总控 leader / 子席位 special
所有 API 调用。Persona 抽屉里 per-seat 设置的 model 优先级更高。
选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */}
<label className="flex items-center gap-1.5 bg-white border border-[#e2e8f0] rounded-xl px-2.5 py-1.5 text-xs font-bold text-[#334155] dark:border-border dark:bg-secondary dark:text-foreground">
<Cpu className="w-3.5 h-3.5 text-primary" />
<span className="text-[10px] text-slate-400 font-mono">模型</span>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="bg-transparent border-none outline-none text-xs font-bold text-slate-700 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed dark:text-foreground"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</label>
<button
id="new-task-btn"
onClick={handleStartNewTask}
@@ -2512,9 +2499,40 @@ export default function RoundtablePlanningPage() {
</button>
</div>
{intentInitError && (
<p className="mt-2 text-[11px] text-red-500 font-medium">
智能体初始化失败: {intentInitError}
</p>
<div className="mt-2 rounded-lg border border-red-200 bg-red-50/70 p-3 text-[11px] leading-relaxed">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-bold text-red-700">智能体初始化失败</span>
{intentInitErrorDetail?.code && (
<span className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded font-mono text-[10px]">
{intentInitErrorDetail.code}
</span>
)}
{intentInitErrorDetail?.exception_type && (
<span className="text-[10px] text-red-600 font-mono">
{intentInitErrorDetail.exception_type}
{intentInitErrorDetail.root_type && intentInitErrorDetail.root_type !== intentInitErrorDetail.exception_type
? ` → ${intentInitErrorDetail.root_type}`
: ""}
</span>
)}
</div>
<p className="text-red-700 font-medium break-all">{intentInitError}</p>
{intentInitErrorDetail?.hint && (
<p className="mt-1.5 text-red-800 bg-amber-50 border-l-2 border-amber-400 px-2 py-1 rounded-sm">
<span className="font-bold">💡 建议:</span> {intentInitErrorDetail.hint}
</p>
)}
{intentInitErrorDetail?.trace_tail && intentInitErrorDetail.trace_tail.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-red-600 hover:text-red-800 select-none text-[10px] font-bold">
展开 traceback 末段(共 {intentInitErrorDetail.trace_tail.length} 行)
</summary>
<pre className="mt-1 max-h-48 overflow-auto bg-slate-900 text-slate-100 text-[10px] leading-snug p-2 rounded font-mono whitespace-pre">
{intentInitErrorDetail.trace_tail.join("\n")}
</pre>
</details>
)}
</div>
)}
</div>
)}
@@ -3652,6 +3670,44 @@ export default function RoundtablePlanningPage() {
</>
) : (
<>
{/* Panel 0: 推理模型选择器
─────────────────────────────────────────────────────
全局选中模型(selectedModel)影响 Step 1 / 推荐 / Step 2 init /
总控 leader / 子席位 special 所有 API 调用。Persona 抽屉里
per-seat 覆盖优先级更高。
选项来自 /api/models(由 config.yaml 配置),内网部署只需配自家模型。 */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
</div>
<select
id="global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
}
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
</div>
{/* Panel 1: 当前意图摘要 */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">