样式修改
This commit is contained in:
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">
|
||||
|
||||
Reference in new issue
Block a user