feat: add AI research chatbot with confirmed business tools

This commit is contained in:
yuxuanhui
2026-09-07 23:02:55 +08:00
parent 3cd280d068
commit 79ab20b4ea
44 changed files with 4647 additions and 197 deletions
+746
View File
@@ -0,0 +1,746 @@
import { useChat } from "@ai-sdk/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Banner,
Button,
Select,
Spin,
Tag,
TextArea,
Toast,
} from "@douyinfe/semi-ui-19";
import {
api,
formatNumber,
formatTime,
jobLabels,
jobStateLabels,
post,
stateLabels,
} from "../api";
import { PnlChart } from "../components/PnlChart";
import type { Alpha, Job, Pnl, Research } from "../types";
import { chatTransport } from "./transport";
import { runLabels, toolLabels } from "./types";
import type {
ChatMessage,
Conversation,
ConversationDetail,
ModelSettings,
PageContext,
RunSnapshot,
ToolCard,
UIAction,
} from "./types";
export function ChatPanel({
open,
context,
onClose,
onAction,
onChanged,
onSettings,
width,
onWidth,
timezone,
jobs,
}: {
open: boolean;
context: PageContext;
onClose: () => void;
onAction: (action: UIAction) => void;
onChanged: () => void;
onSettings: () => void;
width: number;
onWidth: (width: number) => void;
timezone?: string;
jobs: Job[];
}) {
const [settings, setSettings] = useState<ModelSettings | null>(null);
const [conversations, setConversations] = useState<Conversation[]>([]);
const [conversationId, setConversationId] = useState("");
const [runs, setRuns] = useState<RunSnapshot[]>([]);
const [text, setText] = useState("");
const [failure, setFailure] = useState("");
const [loading, setLoading] = useState(false);
const root = useRef<HTMLElement>(null);
const input = useRef<HTMLDivElement>(null);
const bottom = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
const seenWrites = useRef(new Set<string>());
const current = useRef({ conversationId, context });
current.current = { conversationId, context };
const refreshRef = useRef<() => Promise<void>>(async () => {});
const changedRef = useRef(onChanged);
changedRef.current = onChanged;
const observeWrites = useCallback((items: RunSnapshot[]) => {
let changed = false;
for (const run of items)
for (const call of run.tools ?? []) {
if (
call.status === "completed" &&
[
"update_research",
"bulk_update_research",
"create_sync_job",
"cancel_job",
"retry_job",
].includes(call.name) &&
!seenWrites.current.has(call.id)
) {
seenWrites.current.add(call.id);
changed = true;
}
}
if (changed) changedRef.current();
}, []);
const transport = useMemo(() => chatTransport(() => current.current), []);
const chat = useChat<ChatMessage>({
id: conversationId || "unselected",
transport,
experimental_throttle: 50,
onData: (part) => {
if (part.type === "data-run") {
const next = part.data;
if (next.conversation_id !== current.current.conversationId) return;
observeWrites([next]);
setRuns((items) => {
const old = items.find((run) => run.id === next.id);
const merged = {
...old,
...next,
tools: next.tools ?? old?.tools ?? [],
};
return [...items.filter((run) => run.id !== next.id), merged];
});
}
},
onFinish: async () => {
await refreshRef.current();
},
onError: (error) => {
setFailure(error.message);
void refreshRef.current();
},
});
const refreshConversation = useCallback(async () => {
const id = current.current.conversationId;
if (!id) return;
try {
const detail = await api<ConversationDetail>(`/ai/conversations/${id}`);
if (current.current.conversationId !== id) return;
chat.setMessages(detail.messages);
setRuns(detail.runs);
observeWrites(detail.runs);
setConversations((items) =>
items.map((item) =>
item.id === id ? { id, title: detail.title } : item,
),
);
} catch (e) {
setFailure((e as Error).message);
}
}, [chat.setMessages, observeWrites]);
refreshRef.current = refreshConversation;
useEffect(() => {
let active = true;
const loadSettings = () =>
api<ModelSettings>("/ai/settings")
.then((value) => {
if (active) setSettings(value);
})
.catch((e) => {
if (active) setFailure(e.message);
});
void loadSettings();
api<Conversation[]>("/ai/conversations")
.then((items) => {
if (active) {
setConversations(items);
setConversationId(items[0]?.id ?? "");
}
})
.catch((e) => {
if (active) setFailure(e.message);
});
window.addEventListener("ai-settings-changed", loadSettings);
return () => {
active = false;
window.removeEventListener("ai-settings-changed", loadSettings);
};
}, []);
useEffect(() => {
setRuns([]);
setFailure("");
setLoading(true);
void refreshConversation().finally(() => setLoading(false));
}, [conversationId, refreshConversation]);
const streaming = chat.status === "streaming" || chat.status === "submitted";
const activeRun = runs.find((run) =>
["running", "waiting_approval"].includes(run.status),
);
useEffect(() => {
if (!conversationId || streaming || activeRun?.status !== "running") return;
const timer = window.setInterval(() => void refreshRef.current(), 3000);
return () => clearInterval(timer);
}, [conversationId, streaming, activeRun?.status]);
useEffect(() => {
if (!open) return;
previousFocus.current = document.activeElement as HTMLElement;
input.current?.querySelector("textarea")?.focus();
return () => {
requestAnimationFrame(() => {
if (
previousFocus.current?.isConnected &&
previousFocus.current !== document.body
)
previousFocus.current.focus();
else
document
.querySelector<HTMLElement>('[aria-label="打开研究助手"]')
?.focus();
});
};
}, [open]);
useEffect(() => {
if (open) bottom.current?.scrollIntoView({ block: "nearest" });
}, [chat.messages, open]);
async function createConversation() {
setLoading(true);
try {
await chat.stop();
const value = await post<Conversation>("/ai/conversations");
setConversations((items) => [value, ...items]);
setConversationId(value.id);
setText("");
} catch (e) {
setFailure((e as Error).message);
} finally {
setLoading(false);
}
}
async function send() {
if (!text.trim() || !conversationId || activeRun || streaming) return;
const message = text;
setText("");
setFailure("");
chat.clearError();
try {
await chat.sendMessage({ text: message });
} catch (e) {
setFailure((e as Error).message);
}
}
async function decide(id: string, approved: boolean) {
setFailure("");
chat.clearError();
try {
await chat.sendMessage(undefined, {
body: { decision: { id, approved } },
});
} catch (e) {
setFailure((e as Error).message);
}
}
async function stop() {
if (!activeRun) return;
try {
await post(`/ai/runs/${activeRun.id}/cancel`);
await chat.stop();
await refreshConversation();
changedRef.current();
} catch (e) {
setFailure((e as Error).message);
}
}
const cards = new Map(
runs.flatMap((run) => run.tools ?? []).map((call) => [call.id, call]),
);
const lastRun = runs.at(-1);
const ready = !!settings?.enabled && settings.ready;
return (
<aside
ref={root}
hidden={!open}
className="ai-chat"
aria-label="AI 研究助手"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
onClose();
}
if (event.key === "Tab" && window.innerWidth < 1440) {
const elements = [
...(root.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), textarea:not(:disabled), input:not(:disabled), [tabindex="0"]',
) ?? []),
].filter((el) => el.offsetParent !== null);
if (event.shiftKey && document.activeElement === elements[0]) {
event.preventDefault();
elements.at(-1)?.focus();
} else if (
!event.shiftKey &&
document.activeElement === elements.at(-1)
) {
event.preventDefault();
elements[0]?.focus();
}
}
}}
>
<div
className="ai-resize"
role="separator"
aria-label="调整助手宽度"
aria-orientation="vertical"
aria-valuemin={360}
aria-valuemax={640}
aria-valuenow={width}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") onWidth(Math.min(640, width + 20));
if (event.key === "ArrowRight") onWidth(Math.max(360, width - 20));
}}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
onWidth(
Math.max(360, Math.min(640, window.innerWidth - event.clientX)),
);
}}
/>
<header className="ai-header">
<div>
<strong>AI 研究助手</strong>
<span>与你一起查看、分析和记录</span>
</div>
<Button aria-label="收起研究助手" theme="borderless" onClick={onClose}>
收起
</Button>
</header>
<div className="ai-conversations">
<Select
aria-label="选择聊天会话"
value={conversationId || undefined}
placeholder="选择会话"
optionList={conversations.map((item) => ({
value: item.id,
label: item.title,
}))}
onChange={async (value) => {
await chat.stop();
setConversationId(String(value));
}}
disabled={loading}
/>
<Button onClick={() => void createConversation()} disabled={loading}>
新会话
</Button>
</div>
{!ready && (
<Banner
type="info"
description={
<span>
配置并测试模型服务后即可聊天。
<button className="text-link" onClick={onSettings}>
前往配置
</button>
</span>
}
/>
)}
{failure && <Banner type="danger" description={failure} />}
<div className="ai-messages" aria-live="polite" aria-busy={streaming}>
{loading && <Spin />}
{!chat.messages.length && (
<div className="ai-empty">
<strong>从当前研究出发</strong>
<p>可以让我筛选 Alpha、解释已有指标,或提出研究记录修改。</p>
{!conversationId && (
<Button
onClick={() => void createConversation()}
disabled={loading}
>
开始新会话
</Button>
)}
<span>修改和任务操作会先展示预览,确认后执行。</span>
</div>
)}
{chat.messages.map((message) => (
<article className={`ai-message ${message.role}`} key={message.id}>
<span className="ai-role">
{message.role === "user" ? "你" : "研究助手"}
</span>
{message.parts.map((part, index) =>
part.type === "text" ? (
<div className="ai-text" key={index}>
{part.text}
</div>
) : part.type === "data-tool" ? (
<BusinessCard
key={part.id ?? index}
call={cards.get(part.data.id) ?? part.data}
onDecision={decide}
disabled={
streaming || activeRun?.status !== "waiting_approval"
}
onAction={onAction}
timezone={timezone}
jobs={jobs}
/>
) : null,
)}
</article>
))}
{lastRun && (
<div className="ai-run-state">
<Tag color={lastRun.status === "failed" ? "red" : "grey"}>
{runLabels[lastRun.status]}
</Tag>
{lastRun.error && <p className="error-text">{lastRun.error}</p>}
{lastRun.usage && (
<small>
{lastRun.model} · {(lastRun.elapsed_ms / 1000).toFixed(1)} 秒 ·{" "}
{lastRun.usage.token_usage_known &&
(lastRun.usage.input_tokens ?? 0) +
(lastRun.usage.output_tokens ?? 0) >
0
? `${lastRun.usage.input_tokens} 输入 / ${lastRun.usage.output_tokens} 输出 tokens`
: "用量未提供"}
</small>
)}
</div>
)}
<div ref={bottom} />
</div>
<footer className="ai-composer" ref={input}>
<div className="ai-context">
{context.page === "account"
? "上下文:个人信息页"
: `上下文:${context.alpha_id ? `Alpha ${context.alpha_id}` : "Alpha 列表"}${context.selected_ids?.length ? ` · 已选 ${context.selected_ids.length} 条` : ""}`}
</div>
<TextArea
aria-label="发送给研究助手"
placeholder={
conversationId
? "询问当前 Alpha,或描述要执行的操作…"
: "请先新建会话"
}
value={text}
onChange={setText}
rows={3}
maxCount={20000}
disabled={!ready || !conversationId || loading}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void send();
}
}}
/>
<div className="ai-send">
<small>Enter 发送 · Shift + Enter 换行</small>
{activeRun ? (
<Button onClick={() => void stop()}>停止生成</Button>
) : (
<Button
theme="solid"
disabled={
!ready ||
!text.trim() ||
!conversationId ||
loading ||
streaming
}
onClick={() => void send()}
>
发送
</Button>
)}
</div>
</footer>
</aside>
);
}
function BusinessCard({
call,
onDecision,
disabled,
onAction,
timezone,
jobs,
}: {
call: ToolCard;
onDecision: (id: string, approved: boolean) => Promise<void>;
disabled: boolean;
onAction: (action: UIAction) => void;
timezone?: string;
jobs: Job[];
}) {
const result = call.result ?? {};
const jobId = result.job_id ?? result.id;
const job = jobs.find((item) => item.id === jobId);
const jobOperation = [
"get_job_status",
"create_sync_job",
"retry_job",
"cancel_job",
].includes(call.name);
const pending = call.status === "pending";
const items = Array.isArray(result.items) ? result.items : [];
const isSearch = call.name === "search_alphas";
const alphas = (
isSearch ? items : call.name === "get_alpha" && result.id ? [result] : []
) as Alpha[];
const labels: Record<string, string> = {
pending: "待确认",
completed: "已完成",
failed: "失败",
denied: "已拒绝",
cancelled: "已取消",
};
const openAlpha = (id: string) =>
onAction({ type: "open_alpha", alpha_id: id, nonce: Date.now() });
return (
<section className="ai-tool-card">
<div className="ai-card-title">
<strong>{toolLabels[call.name] ?? "业务操作"}</strong>
<Tag
color={pending ? "orange" : call.status === "failed" ? "red" : "grey"}
>
{labels[call.status] ?? call.status}
</Tag>
</div>
{call.preview.targets?.map((target) => (
<details
key={target.alpha_id}
open={call.preview.targets!.length === 1}
>
<summary>{target.alpha_id} 的修改</summary>
{(["note", "tags", "favorite", "state"] as const)
.filter(
(key) =>
JSON.stringify(target.before[key]) !==
JSON.stringify(target.after[key]),
)
.map((key) => (
<div className="ai-diff" key={key}>
<strong>
{
{
note: "备注",
tags: "标签",
favorite: "收藏",
state: "研究状态",
}[key]
}
</strong>
<del>{researchValue(key, target.before[key])}</del>
<ins>{researchValue(key, target.after[key])}</ins>
</div>
))}
</details>
))}
{call.preview.operation && (
<p>
{jobLabels[String(call.preview.operation.kind)] ?? "创建任务"} ·{" "}
{Array.isArray(call.preview.operation.alpha_ids) &&
call.preview.operation.alpha_ids.length
? call.preview.operation.alpha_ids.join("、")
: "全部 Alpha"}
</p>
)}
{call.preview.job && (
<p>
{jobLabels[String(call.preview.job.kind)]} ·{" "}
{String(call.preview.job.id)} ·{" "}
{jobStateLabels[String(call.preview.job.status)]}
</p>
)}
{pending && (
<div className="inline-actions">
<Button
theme="solid"
disabled={disabled}
onClick={() => void onDecision(call.id, true)}
>
确认执行
</Button>
<Button
disabled={disabled}
onClick={() => void onDecision(call.id, false)}
>
拒绝
</Button>
</div>
)}
{typeof result.error === "string" && (
<p className="error-text">{result.error}</p>
)}
{result.denied === true && <p>已拒绝,未执行此操作。</p>}
{alphas.length > 0 && (
<div className="ai-alpha-results">
{alphas.map((alpha) => (
<div key={alpha.id}>
<button className="text-link" onClick={() => openAlpha(alpha.id)}>
{alpha.name || alpha.id}
</button>
<small>
{alpha.id} · {alpha.region ?? "地区未提供"}
</small>
<table>
<thead>
<tr>
<th>Sharpe</th>
<th>Fitness</th>
<th>Turnover</th>
</tr>
</thead>
<tbody>
<tr>
<td>{formatNumber(alpha.sharpe)}</td>
<td>{formatNumber(alpha.fitness)}</td>
<td>
{alpha.turnover == null
? "未提供"
: `${formatNumber(alpha.turnover * 100)}%`}
</td>
</tr>
</tbody>
</table>
<small>本地快照 · {formatTime(alpha.synced_at, timezone)}</small>
</div>
))}
</div>
)}
{isSearch && (
<p>
共 {String(result.total ?? 0)} 条,当前返回 {items.length} 条。
{!!result.filters && (
<button
className="text-link"
onClick={() =>
onAction({
type: "apply_filters",
filters: result.filters as Record<string, unknown>,
nonce: Date.now(),
})
}
>
应用到列表
</button>
)}
</p>
)}
{call.name === "get_alpha_facets" && call.status === "completed" && (
<p>
已同步 {String(result.total ?? 0)} 条 Alpha · 收藏{" "}
{String(result.favorites ?? 0)} 条<br />
地区:
{Array.isArray(result.region) ? result.region.join("、") : "未提供"}
</p>
)}
{call.name === "get_alpha_pnl" && call.status === "completed" && (
<ChatPnl result={result} timezone={timezone} />
)}
{call.name === "list_jobs" &&
items.map((item) => {
const job = item as Record<string, unknown>;
return (
<p key={String(job.id)}>
{jobLabels[String(job.kind)]} ·{" "}
{jobStateLabels[String(job.status)]}
<small>{String(job.id)}</small>
</p>
);
})}
{jobOperation && !!jobId && (
<p>
{jobLabels[job?.kind ?? String(result.kind)] ?? "任务"} ·{" "}
{jobStateLabels[job?.status ?? String(result.status)] ??
String(result.status ?? "已处理")}
{job && (
<span>
{" "}
· {job.processed}/{job.total ?? "未知"},失败 {job.failed}
{job.error ? ` · ${job.error}` : ""}
</span>
)}
<small>{String(jobId)}</small>
</p>
)}
{result.ok === true && (
<p>
操作已保存。
{typeof result.alpha_id === "string" && (
<button
className="text-link"
onClick={() => openAlpha(result.alpha_id as string)}
>
打开 Alpha
</button>
)}
</p>
)}
{typeof result.updated === "number" && (
<p>已更新 {result.updated} 条研究记录。</p>
)}
</section>
);
}
function researchValue(key: keyof Research, value: unknown) {
if (key === "favorite") return value ? "已收藏" : "未收藏";
if (key === "state")
return (
stateLabels[String(value) as keyof typeof stateLabels] ?? String(value)
);
if (Array.isArray(value)) return value.join("、") || "无";
return String(value || "空");
}
function ChatPnl({
result,
timezone,
}: {
result: Record<string, unknown>;
timezone?: string;
}) {
const [pnl, setPnl] = useState<Pnl | null>(null);
useEffect(() => {
let active = true;
if (result.cached && typeof result.alpha_id === "string")
api<Pnl>(`/alphas/${encodeURIComponent(result.alpha_id)}/pnl`)
.then((value) => {
if (active) setPnl(value);
})
.catch((error) => {
if (active) Toast.error(error.message);
});
return () => {
active = false;
};
}, [result.alpha_id, result.fetched_at, result.cached]);
return result.cached ? (
<div>
{pnl && <PnlChart points={pnl.points} />}
<small>
{String(result.count)} 条记录 · 缓存于{" "}
{formatTime(result.fetched_at as string, timezone)}
</small>
</div>
) : (
<p>尚无 PnL 缓存,可以请求创建 PnL 刷新任务。</p>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { useEffect, useState } from "react";
import {
Banner,
Button,
Checkbox,
Input,
Select,
Tag,
Toast,
} from "@douyinfe/semi-ui-19";
import { api, post } from "../api";
import type { ModelSettings } from "./types";
export function ModelSettingsPanel() {
const [saved, setSaved] = useState<ModelSettings | null>(null);
const [draft, setDraft] = useState({
base_url: "",
model: "",
protocol: "chat_completions",
enabled: false,
});
const [key, setKey] = useState("");
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
function apply(value: ModelSettings) {
setSaved(value);
setDraft({
base_url: value.base_url,
model: value.model,
protocol: value.protocol,
enabled: value.enabled,
});
window.dispatchEvent(new Event("ai-settings-changed"));
}
useEffect(() => {
let active = true;
api<ModelSettings>("/ai/settings")
.then((value) => {
if (active) apply(value);
})
.catch((e) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, []);
const changed =
!!key ||
!saved ||
["base_url", "model", "protocol"].some(
(k) => draft[k as keyof typeof draft] !== saved[k as keyof ModelSettings],
);
async function save() {
setBusy("save");
setError("");
try {
const value = await api<ModelSettings>("/ai/settings", {
method: "PUT",
body: JSON.stringify({ ...draft, ...(key ? { api_key: key } : {}) }),
});
apply(value);
setKey("");
Toast.success("模型配置已保存");
} catch (e) {
setError((e as Error).message);
} finally {
setBusy("");
}
}
async function test() {
setBusy("test");
setError("");
try {
apply(await post<ModelSettings>("/ai/settings/test"));
} catch (e) {
setError((e as Error).message);
} finally {
setBusy("");
}
}
return (
<section className="panel full-width ai-settings">
<div className="panel-title">
<div>
<h2>大模型服务</h2>
<p>配置你的 OpenAI 兼容服务,供研究助手使用。</p>
</div>
<Tag color={saved?.enabled && saved.ready ? "green" : "grey"}>
{saved?.enabled && saved.ready ? "已启用" : "未启用"}
</Tag>
</div>
{error && <Banner type="danger" description={error} />}
<form
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<div className="ai-settings-grid">
<label>
Base URL
<Input
aria-label="模型 Base URL"
value={draft.base_url}
onChange={(base_url) => setDraft({ ...draft, base_url })}
placeholder="https://example.com/v1"
/>
</label>
<label>
模型标识
<Input
aria-label="模型标识"
value={draft.model}
onChange={(model) => setDraft({ ...draft, model })}
placeholder="供应商提供的模型名称"
/>
</label>
<label>
API Key
<Input
aria-label="模型 API Key"
mode="password"
autoComplete="new-password"
value={key}
onChange={setKey}
placeholder={
saved?.configured
? "已保存;留空保留,更换地址需重填"
: "输入服务密钥"
}
/>
</label>
<label>
接口协议
<Select
aria-label="模型接口协议"
value={draft.protocol}
onChange={(protocol) =>
setDraft({ ...draft, protocol: String(protocol) })
}
optionList={[
{ value: "chat_completions", label: "Chat Completions" },
{ value: "responses", label: "Responses" },
]}
/>
</label>
</div>
<p className="muted">
填写服务端可访问的 API 根地址,按供应商要求包含 /v1;无需填写
/chat/completions 或 /responses。密钥加密保存在后端。
</p>
<Checkbox
checked={draft.enabled}
disabled={!saved?.ready || changed}
onChange={(e) => setDraft({ ...draft, enabled: !!e.target.checked })}
>
启用研究助手(能力测试通过后可开启)
</Checkbox>
<div className="inline-actions ai-settings-actions">
<Button
htmlType="submit"
theme="solid"
loading={busy === "save"}
disabled={!!busy || !draft.base_url || !draft.model}
>
保存模型配置
</Button>
<Button
loading={busy === "test"}
disabled={!!busy || changed || !saved?.configured}
onClick={() => void test()}
>
测试连接
</Button>
</div>
<p className="muted">
测试连接会向已保存的服务发送少量合成请求,按供应商规则计费。
</p>
<div className="ai-test-results">
{Object.entries(saved?.test_results ?? {}).map(([name, result]) => (
<div key={name}>
<Tag color={result.ok ? "green" : "red"}>
{
(
{
answer: "回答",
stream: "流式输出",
tools: "工具调用",
} as Record<string, string>
)[name]
}
</Tag>
<span>{result.message}</span>
</div>
))}
</div>
</form>
</section>
);
}
+231
View File
@@ -0,0 +1,231 @@
[hidden] {
display: none !important;
}
.workspace {
padding-right: var(--chat-space, 0px);
}
.ai-chat {
position: fixed;
inset: 0 0 0 auto;
width: min(var(--chat-width, 420px), 100vw);
z-index: 1001;
display: flex;
flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--line);
box-shadow: -8px 0 32px #151b2c0b;
color: var(--ink);
}
.ai-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 20px 16px;
border-bottom: 1px solid var(--line);
}
.ai-header strong {
font-size: 17px;
}
.ai-header span {
display: block;
font-size: 11px;
color: var(--muted);
margin-top: 6px;
}
.ai-conversations {
display: flex;
gap: 8px;
padding: 14px 16px;
}
.ai-conversations .semi-select {
flex: 1;
min-width: 0;
}
.ai-messages {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 18px 24px;
}
.ai-message {
margin: 0 0 24px;
overflow-wrap: anywhere;
}
.ai-role {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 8px;
}
.ai-text {
white-space: pre-wrap;
line-height: 1.8;
}
.ai-message.user .ai-text {
background: var(--semi-color-primary-light-default);
padding: 12px 14px;
border-radius: 10px;
}
.ai-empty {
margin: 48px 8px;
color: var(--muted);
line-height: 1.8;
}
.ai-empty strong {
display: block;
color: var(--ink);
font-size: 18px;
margin-bottom: 10px;
}
.ai-empty span {
display: block;
font-size: 12px;
margin-top: 16px;
}
.ai-composer {
padding: 14px 16px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.ai-context {
color: var(--muted);
font-size: 11px;
margin-bottom: 10px;
}
.ai-send {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 10px;
}
.ai-send small,
.ai-tool-card small,
.ai-run-state small {
color: var(--muted);
font-size: 11px;
}
.ai-run-state small,
.ai-tool-card small {
display: block;
margin-top: 6px;
}
.ai-tool-card {
border: 1px solid var(--line);
border-radius: 9px;
padding: 12px;
margin: 12px 0;
font-size: 12px;
}
.ai-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.ai-tool-card p {
margin: 10px 0;
}
.ai-tool-card summary {
cursor: pointer;
padding: 8px 0;
}
.ai-diff {
display: grid;
gap: 6px;
margin: 8px 0 16px;
}
.ai-diff del,
.ai-diff ins {
display: block;
padding: 8px;
white-space: pre-wrap;
border-radius: 4px;
}
.ai-diff del {
background: #f5636310;
text-decoration: none;
border-left: 2px solid #c35757;
}
.ai-diff ins {
background: #32925c10;
text-decoration: none;
border-left: 2px solid #32925c;
}
.ai-alpha-results > div {
padding: 10px 0;
border-top: 1px solid var(--line);
}
.ai-alpha-results table {
width: 100%;
text-align: left;
margin-top: 10px;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
.ai-alpha-results th {
color: var(--muted);
font-weight: 400;
font-size: 10px;
padding-bottom: 4px;
}
.ai-resize {
position: absolute;
left: -4px;
top: 0;
height: 100%;
width: 8px;
cursor: col-resize;
touch-action: none;
}
.ai-resize:hover,
.ai-resize:focus-visible {
background: var(--accent);
opacity: 0.3;
}
.ai-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 24px;
}
.ai-settings .inline-actions {
margin-top: 14px;
}
.ai-settings .panel-title {
display: flex;
justify-content: space-between;
gap: 16px;
}
.text-link {
border: none;
background: transparent;
color: var(--accent);
padding: 0 3px;
cursor: pointer;
}
@media (max-width: 1439px) {
.ai-chat {
width: min(var(--chat-width, 420px), 100vw);
box-shadow: -30px 0 120px #151b2c30;
}
}
@media (max-width: 640px) {
.ai-chat {
width: 100vw;
}
.ai-resize {
display: none;
}
.ai-settings-grid {
grid-template-columns: 1fr;
}
}
.ai-launcher {
position: fixed;
right: 20px;
bottom: 24px;
z-index: 1002;
box-shadow: 0 4px 18px #29213e24;
}
+49
View File
@@ -0,0 +1,49 @@
import { DefaultChatTransport } from "ai";
import type { ChatMessage, PageContext } from "./types";
/** Send only new input. Provider history and tool results are owned by the server. */
export function chatTransport(
current: () => { conversationId: string; context: PageContext },
) {
return new DefaultChatTransport<ChatMessage>({
credentials: "same-origin",
headers: { "X-WQ-Request": "1" },
fetch: async (url, init) => {
const response = await fetch(url, init);
if (!response.ok) {
if (response.status === 401)
window.dispatchEvent(new Event("session-expired"));
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body.detail === "string"
? body.detail
: `请求失败(${response.status})`,
);
}
return response;
},
prepareSendMessagesRequest: ({ messages, body }) => {
const state = current();
const decision = body?.decision as
| { id: string; approved: boolean }
| undefined;
if (decision)
return {
api: `/api/v1/ai/approvals/${encodeURIComponent(decision.id)}/decision`,
body: { approved: decision.approved },
};
const last = messages.filter((message) => message.role === "user").at(-1);
return {
api: `/api/v1/ai/conversations/${encodeURIComponent(state.conversationId)}/runs`,
body: {
request_id: last?.id,
message: last?.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n"),
context: state.context,
},
};
},
});
}
+78
View File
@@ -0,0 +1,78 @@
import type { UIMessage } from "ai";
import type { Research } from "../types";
export type ModelSettings = {
base_url: string;
model: string;
protocol: "chat_completions" | "responses";
configured: boolean;
enabled: boolean;
ready: boolean;
test_results: Record<string, { ok: boolean; message: string }>;
};
export type PageContext = {
page: "alphas" | "account";
alpha_id?: string | null;
selected_ids?: string[];
filters?: Record<string, unknown>;
};
export type UIAction =
| { type: "open_alpha"; alpha_id: string; nonce: number }
| { type: "apply_filters"; filters: Record<string, unknown>; nonce: number };
export type ToolCard = {
id: string;
name: string;
status: string;
preview: {
targets?: { alpha_id: string; before: Research; after: Research }[];
job?: Record<string, unknown>;
operation?: Record<string, unknown>;
};
result: Record<string, unknown> | null;
};
export type RunSnapshot = {
id: string;
conversation_id: string;
status: string;
error?: string | null;
model: string;
elapsed_ms: number;
usage: {
token_usage_known?: boolean;
input_tokens?: number;
output_tokens?: number;
requests?: number;
};
tools: ToolCard[];
};
export type ChatMessage = UIMessage<
unknown,
{ tool: ToolCard; run: RunSnapshot }
>;
export type Conversation = { id: string; title: string };
export type ConversationDetail = Conversation & {
messages: ChatMessage[];
runs: RunSnapshot[];
};
export const runLabels: Record<string, string> = {
running: "执行中",
waiting_approval: "等待确认",
completed: "已完成",
failed: "执行失败",
cancelled: "已停止",
interrupted: "执行中断",
};
export const toolLabels: Record<string, string> = {
search_alphas: "查询 Alpha",
get_alpha_facets: "查询筛选选项",
get_alpha: "读取 Alpha",
get_alpha_pnl: "读取 PnL 缓存",
list_jobs: "查询任务",
get_job_status: "查看任务状态",
update_research: "修改研究记录",
bulk_update_research: "批量修改研究记录",
create_sync_job: "创建同步任务",
cancel_job: "取消任务",
retry_job: "重试任务",
};