feat: 重写原生AI对话页面

This commit is contained in:
baiyanyun
2026-07-02 17:44:13 +08:00
parent 38adf72939
commit f0a2986cf3
7 changed files with 2348 additions and 3 deletions

View File

@@ -42,6 +42,10 @@ import AboutPage from "./components/pages/AboutPage";
import LogViewer from "./components/pages/LogViewer"; import LogViewer from "./components/pages/LogViewer";
import PermissionsPage from "./components/pages/PermissionsPage"; import PermissionsPage from "./components/pages/PermissionsPage";
import SessionsPage from "./components/pages/SessionsPage"; import SessionsPage from "./components/pages/SessionsPage";
import AiChatPage from "./components/pages/AiChatPage";
import SkillsMarketplacePage from "./components/pages/SkillsMarketplacePage";
import McpMarketplacePage from "./components/pages/McpMarketplacePage";
import WorkflowMarketplacePage from "./components/pages/WorkflowMarketplacePage";
import MCPSettings from "./components/settings/MCPSettings"; import MCPSettings from "./components/settings/MCPSettings";
import AppSidebar, { import AppSidebar, {
NavKey, NavKey,
@@ -94,6 +98,10 @@ export function useI18nLang(): I18nContextValue {
type TabKey = type TabKey =
| "client" | "client"
| "sessions" | "sessions"
| "legacySessions"
| "skills"
| "mcpLibrary"
| "workflow"
| "mcp" | "mcp"
| "settings" | "settings"
| "dependencies" | "dependencies"
@@ -989,8 +997,18 @@ function App() {
return; return;
} }
if (key === "skills") {
setActiveTab("skills");
return;
}
if (key === "mcpLibrary") { if (key === "mcpLibrary") {
setActiveTab("mcp"); setActiveTab("mcpLibrary");
return;
}
if (key === "workflow") {
setActiveTab("workflow");
return; return;
} }
@@ -1003,6 +1021,12 @@ function App() {
}, []); }, []);
const handleLegacySelect = useCallback((key: LegacyTabKey) => { const handleLegacySelect = useCallback((key: LegacyTabKey) => {
if (key === "sessions") {
setActiveTab("legacySessions");
setActiveNavKey("aiChat");
return;
}
setActiveTab(key); setActiveTab(key);
const navByLegacyTab: Partial<Record<LegacyTabKey, NavKey>> = { const navByLegacyTab: Partial<Record<LegacyTabKey, NavKey>> = {
client: "overview", client: "overview",
@@ -1176,12 +1200,20 @@ function App() {
/> />
)} )}
{activeTab === "sessions" && ( {activeTab === "sessions" && (
<AiChatPage
autoNew={sessionsAutoOpen}
onAutoNewConsumed={() => setSessionsAutoOpen(false)}
/>
)}
{activeTab === "legacySessions" && (
<SessionsPage <SessionsPage
autoOpen={sessionsAutoOpen} autoOpen={false}
onAutoOpenConsumed={() => setSessionsAutoOpen(false)}
onWebviewChange={setWebviewActions} onWebviewChange={setWebviewActions}
/> />
)} )}
{activeTab === "skills" && <SkillsMarketplacePage />}
{activeTab === "mcpLibrary" && <McpMarketplacePage />}
{activeTab === "workflow" && <WorkflowMarketplacePage />}
{activeTab === "mcp" && <MCPSettings />} {activeTab === "mcp" && <MCPSettings />}
{activeTab === "settings" && <SettingsPage />} {activeTab === "settings" && <SettingsPage />}
{activeTab === "dependencies" && <DependenciesPage />} {activeTab === "dependencies" && <DependenciesPage />}

View File

@@ -0,0 +1,647 @@
import { message } from "antd";
import {
AUTH_KEYS,
DEFAULT_API_TIMEOUT,
DEFAULT_SERVER_HOST,
} from "@shared/constants";
import { getDomainTokenKey } from "@shared/utils/domain";
import { getCurrentAuth } from "./auth";
import { normalizeServerHost } from "./auth";
const SUCCESS_CODE = "0000";
const AI_MESSAGE_PAGE_SIZE = 20;
interface ApiResponse<T> {
code: string;
message?: string;
data: T;
success?: boolean;
}
interface AiRequestOptions {
method?: "GET" | "POST";
data?: unknown;
params?: Record<string, unknown>;
headers?: Record<string, string>;
showError?: boolean;
timeout?: number;
}
export type AiMessageRole = "USER" | "ASSISTANT" | "SYSTEM" | "FUNCTION";
export type AiMessageMode = "CHAT" | "THINK" | "GUID" | "QUESTION" | "ANSWER";
export type AiMessageType = "USER" | "ASSISTANT" | "SYSTEM" | "TOOL";
export type AiMessageStatus =
| "loading"
| "incomplete"
| "complete"
| "error"
| "stopped"
| null;
export type AiConversationEventType =
| "PROCESSING"
| "MESSAGE"
| "FINAL_RESULT"
| "ERROR";
export interface AiPublishedUser {
id?: number;
name?: string;
username?: string;
nickName?: string;
avatar?: string;
}
export interface AiAttachment {
fileKey: string;
fileUrl: string;
fileName: string;
mimeType: string;
}
export interface AiUploadFileInfo {
uid: string;
url: string;
name: string;
type: string;
key?: string;
size: number;
}
export interface AiSelectedComponent {
id: number;
type: string;
}
export interface AiManualComponent {
id: number;
name: string;
icon?: string;
description?: string;
type: string;
defaultSelected?: number;
}
export interface AiSkillMention {
id: number;
targetId: number;
name: string;
description?: string;
icon?: string;
category?: string;
collect?: boolean;
publishUser?: AiPublishedUser;
}
export interface AiModelOption {
id: number;
name: string;
description?: string;
model?: string;
isReasonModel?: number;
enabled?: number;
}
export interface AiConversationAgent {
name?: string;
description?: string;
icon?: string;
type?: string;
category?: string;
manualComponents?: AiManualComponent[];
variables?: unknown[];
guidQuestionDtos?: Array<{
content?: string;
question?: string;
[key: string]: unknown;
}>;
openingChatMsg?: string;
openSuggest?: string;
hasPermission?: boolean;
isSandboxUnavailable?: boolean;
sandboxId?: string;
allowPrivateSandbox?: number;
[key: string]: unknown;
}
export interface AiConversationInfo {
id: number;
uid?: string;
agentId: number;
topic?: string;
summary?: string;
modified?: string;
created?: string;
agent?: AiConversationAgent;
messageList?: AiMessage[];
sandboxServerId?: string;
sandboxSessionId?: string;
taskStatus?: string;
[key: string]: unknown;
}
export interface AiProcessingInfo {
executeId?: string;
targetId?: number;
name?: string;
type?: string;
status?: string;
result?: {
executeId?: string;
success?: boolean;
error?: string;
input?: Record<string, unknown>;
[key: string]: unknown;
} | null;
cardBindConfig?: unknown;
cardData?: unknown;
subEventType?: string | null;
[key: string]: unknown;
}
export interface AiFinalResult {
success?: boolean;
error?: string;
outputText?: string;
totalTokens?: number;
promptTokens?: number;
completionTokens?: number;
startTime?: number;
endTime?: number;
componentExecuteResults?: unknown[];
[key: string]: unknown;
}
export interface AiMessage {
id: string | number;
role: AiMessageRole;
type: AiMessageMode;
messageType: AiMessageType;
text?: string;
think?: string;
time?: string | number;
attachments?: AiAttachment[];
status?: AiMessageStatus;
processingList?: AiProcessingInfo[];
finalResult?: AiFinalResult;
requestId?: string;
metadata?: unknown;
componentExecutedList?: unknown[];
[key: string]: unknown;
}
export interface AiChatEvent {
completed?: boolean;
data: any;
error?: string;
eventType: AiConversationEventType;
requestId: string;
}
export interface SendAiMessageParams {
conversationId: number;
variableParams?: Record<string, string | number>;
message: string;
attachments: AiAttachment[];
debug?: boolean;
selectedComponents: AiSelectedComponent[];
sandboxId?: string;
skillIds?: number[];
modelId?: number;
}
export interface AiAgentContext {
agentId: number;
configId?: number;
domain: string;
}
export interface AiStreamController {
abort: () => void;
done: Promise<void>;
}
function randomId(prefix = "ai"): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
async function getBaseUrl(): Promise<string> {
const auth = await getCurrentAuth();
if (auth.userInfo?.currentDomain) {
return normalizeServerHost(auth.userInfo.currentDomain);
}
const step1 = (await window.electronAPI?.settings.get("step1_config")) as {
serverHost?: string;
} | null;
return normalizeServerHost(step1?.serverHost || DEFAULT_SERVER_HOST);
}
async function getAuthToken(domain: string): Promise<string | null> {
const oneShotToken = (await window.electronAPI?.settings.get(
AUTH_KEYS.AUTH_TOKEN,
)) as string | null;
if (oneShotToken) return oneShotToken;
const domainTokenKey = getDomainTokenKey(domain);
return (await window.electronAPI?.settings.get(domainTokenKey)) as
| string
| null;
}
async function getAuthHeaders(
baseUrl: string,
): Promise<Record<string, string>> {
const token = await getAuthToken(baseUrl);
return token ? { Authorization: `Bearer ${token}` } : {};
}
function appendParams(url: string, params?: Record<string, unknown>): string {
if (!params) return url;
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `${url}?${query}` : url;
}
async function aiRequest<T>(
path: string,
options: AiRequestOptions = {},
): Promise<T> {
const baseUrl = await getBaseUrl();
const authHeaders = await getAuthHeaders(baseUrl);
const finalUrl = appendParams(`${baseUrl}${path}`, options.params);
const response = await fetch(finalUrl, {
method: options.method || "POST",
credentials: "include",
cache: "no-store",
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
...authHeaders,
...options.headers,
},
signal: AbortSignal.timeout(options.timeout || DEFAULT_API_TIMEOUT),
...(options.data ? { body: JSON.stringify(options.data) } : {}),
});
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
if (options.showError !== false) message.error(error.message);
throw error;
}
const result = (await response.json()) as ApiResponse<T>;
if (result.code !== SUCCESS_CODE) {
const errorMessage = result.message || "请求失败";
if (options.showError !== false) message.error(errorMessage);
throw new Error(errorMessage);
}
return result.data;
}
async function fetchSandboxAgentId(configId?: number): Promise<number | null> {
if (!configId) return null;
const configs = await aiRequest<Array<{ id: number; agentId?: number }>>(
"/api/sandbox/config/list",
{ method: "GET", showError: false },
);
const current = configs.find((item) => item.id === configId);
return current?.agentId || null;
}
export async function resolveAiAgentContext(): Promise<AiAgentContext> {
const auth = await getCurrentAuth();
if (!auth.isLoggedIn) {
throw new Error("请先登录");
}
const domain = await getBaseUrl();
const configId = auth.userInfo?.id;
let agentId = auth.userInfo?.agentId || null;
if (!agentId) {
agentId = await fetchSandboxAgentId(configId);
if (agentId && auth.userInfo) {
await window.electronAPI?.settings.set(AUTH_KEYS.USER_INFO, {
...auth.userInfo,
agentId,
});
}
}
if (!agentId) {
throw new Error("当前客户端未绑定智能体,请重新登录或同步配置");
}
return { agentId, configId, domain };
}
export async function fetchAiConversations(params: {
agentId: number;
lastId?: number | null;
limit?: number;
topic?: string;
}): Promise<AiConversationInfo[]> {
return aiRequest<AiConversationInfo[]>("/api/agent/conversation/list", {
method: "POST",
data: {
agentId: params.agentId,
lastId: params.lastId ?? null,
limit: params.limit || AI_MESSAGE_PAGE_SIZE,
topic: params.topic || undefined,
},
});
}
export async function createAiConversation(
agentId: number,
): Promise<AiConversationInfo> {
return aiRequest<AiConversationInfo>("/api/agent/conversation/create", {
method: "POST",
data: { agentId, devMode: false },
});
}
export async function fetchAiConversationDetail(
conversationId: number,
): Promise<AiConversationInfo> {
return aiRequest<AiConversationInfo>(
`/api/agent/conversation/${conversationId}`,
{ method: "POST" },
);
}
export async function fetchAiMessages(params: {
conversationId: number;
index?: number;
size?: number;
}): Promise<AiMessage[]> {
return aiRequest<AiMessage[]>("/api/agent/conversation/message/list", {
method: "POST",
data: {
conversationId: params.conversationId,
index: params.index || Number.MAX_SAFE_INTEGER,
size: params.size || AI_MESSAGE_PAGE_SIZE,
},
});
}
export async function stopAiConversation(requestId: string): Promise<void> {
await aiRequest<null>(
`/api/agent/conversation/chat/stop/${encodeURIComponent(requestId)}`,
{ method: "POST" },
);
}
export async function updateAiConversationTopic(params: {
id: number;
firstMessage?: string;
topic?: string;
}): Promise<AiConversationInfo> {
return aiRequest<AiConversationInfo>("/api/agent/conversation/update", {
method: "POST",
data: params,
showError: false,
});
}
export async function fetchAiModelOptions(
agentId: number,
): Promise<AiModelOption[]> {
return aiRequest<AiModelOption[]>(
`/api/agent/conversation/model/options/${agentId}`,
{ method: "GET", showError: false },
);
}
export async function fetchAtSkills(params: {
type: "all" | "recent" | "favorite";
kw?: string;
page?: number;
pageSize?: number;
}): Promise<AiSkillMention[]> {
const data = {
page: params.page || 1,
pageSize: params.pageSize || 20,
kw: params.kw || undefined,
targetType: "Skill",
};
if (params.type === "favorite") {
return aiRequest<AiSkillMention[]>("/api/published/skill/collect/list", {
method: "POST",
data,
showError: false,
});
}
if (params.type === "recent") {
return aiRequest<AiSkillMention[]>(
"/api/published/skill/recentlyUsed/list",
{
method: "POST",
data,
showError: false,
},
);
}
const page = await aiRequest<{ records?: AiSkillMention[] }>(
"/api/published/skill/list-for-at",
{
method: "POST",
data,
showError: false,
},
);
return page.records || [];
}
export async function uploadAiAttachment(
file: File,
): Promise<AiUploadFileInfo> {
const baseUrl = await getBaseUrl();
const authHeaders = await getAuthHeaders(baseUrl);
const formData = new FormData();
formData.append("file", file);
formData.append("type", "tmp");
const response = await fetch(`${baseUrl}/api/file/upload`, {
method: "POST",
credentials: "include",
headers: {
...authHeaders,
},
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: HTTP ${response.status}`);
}
const result = (await response.json()) as ApiResponse<{
key?: string;
url?: string;
fileName?: string;
mimeType?: string;
size?: number;
}>;
if (result.code !== SUCCESS_CODE) {
throw new Error(result.message || "上传失败");
}
return {
uid: randomId("file"),
key: result.data?.key || "",
url: result.data?.url || "",
name: result.data?.fileName || file.name,
type: result.data?.mimeType || file.type,
size: result.data?.size || file.size,
};
}
function parseSseBlock(block: string): unknown | null {
const dataLines = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.replace(/^data:\s?/, ""));
if (!dataLines.length) return null;
const payload = dataLines.join("\n").trim();
if (!payload || payload === "[DONE]") return null;
return JSON.parse(payload);
}
export function sendAiMessageStream(
params: SendAiMessageParams,
handlers: {
onOpen?: () => void;
onEvent: (event: AiChatEvent) => void;
onError?: (error: Error) => void;
onClose?: (reason: "complete" | "abort" | "error") => void;
},
): AiStreamController {
const controller = new AbortController();
let closeReason: "complete" | "abort" | "error" = "complete";
let closed = false;
const closeOnce = (reason: "complete" | "abort" | "error") => {
if (closed) return;
closed = true;
closeReason = reason;
handlers.onClose?.(reason);
};
const done = (async () => {
try {
const baseUrl = await getBaseUrl();
const authHeaders = await getAuthHeaders(baseUrl);
const response = await fetch(`${baseUrl}/api/agent/conversation/chat`, {
method: "POST",
credentials: "include",
cache: "no-store",
headers: {
Accept: "text/event-stream, application/json, text/plain, */*",
"Content-Type": "application/json",
...authHeaders,
},
body: JSON.stringify(params),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`SSE HTTP ${response.status}: ${response.statusText}`);
}
handlers.onOpen?.();
if (!response.body) {
throw new Error("当前环境不支持流式响应");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done: readerDone } = await reader.read();
if (readerDone) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = blocks.pop() || "";
for (const block of blocks) {
const parsed = parseSseBlock(block);
if (!parsed) continue;
const event = parsed as AiChatEvent;
handlers.onEvent(event);
if (event.completed === true) {
controller.abort();
closeOnce("complete");
return;
}
}
}
if (buffer.trim()) {
const parsed = parseSseBlock(buffer);
if (parsed) handlers.onEvent(parsed as AiChatEvent);
}
closeOnce(closeReason);
} catch (error) {
if (controller.signal.aborted) {
closeOnce(closeReason === "complete" ? "abort" : closeReason);
return;
}
closeOnce("error");
handlers.onError?.(
error instanceof Error ? error : new Error(String(error)),
);
}
})();
return {
abort: () => {
closeReason = "abort";
controller.abort();
},
done,
};
}
export function createLocalUserMessage(
messageText: string,
attachments: AiAttachment[],
): AiMessage {
return {
id: randomId("user"),
role: "USER",
type: "CHAT",
text: messageText,
time: new Date().toISOString(),
attachments,
messageType: "USER",
};
}
export function createLocalAssistantMessage(): AiMessage {
return {
id: randomId("assistant"),
role: "ASSISTANT",
type: "CHAT",
text: "",
think: "",
time: new Date().toISOString(),
messageType: "ASSISTANT",
status: "loading",
};
}

View File

@@ -217,6 +217,8 @@ export interface ClientRegisterResponse {
serverPort?: number; serverPort?: number;
/** 登录态 token用于 webview cookie 同步 */ /** 登录态 token用于 webview cookie 同步 */
token?: string; token?: string;
/** 给当前客户端沙盒配置分配的智能体 ID用于原生 AI 对话 */
agentId?: number;
} }
/** /**

View File

@@ -36,6 +36,7 @@ export interface AuthUserInfo {
email?: string; email?: string;
phone?: string; phone?: string;
currentDomain?: string; currentDomain?: string;
agentId?: number;
} }
// ========== 存储辅助函数 === // ========== 存储辅助函数 ===
@@ -335,6 +336,7 @@ export async function loginAndRegister(
username, username,
displayName: response.name, displayName: response.name,
currentDomain: domain, currentDomain: domain,
agentId: response.agentId,
}); });
await setOnlineStatus(response.online); await setOnlineStatus(response.online);
@@ -503,6 +505,15 @@ export async function reRegisterClient(): Promise<ClientRegisterResponse | null>
await setConfigKey(response.configKey); await setConfigKey(response.configKey);
await setSavedKey(response.configKey, domain, username || undefined); await setSavedKey(response.configKey, domain, username || undefined);
await setOnlineStatus(response.online); await setOnlineStatus(response.online);
const currentUserInfo = await getUserInfo();
await setUserInfo({
...currentUserInfo,
id: response.id,
username: username || "",
displayName: response.name,
currentDomain: domain || currentUserInfo?.currentDomain,
agentId: response.agentId ?? currentUserInfo?.agentId,
} as AuthUserInfo);
// 持久化 token用于 webview cookie 同步) // 持久化 token用于 webview cookie 同步)
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试 // 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
@@ -667,6 +678,7 @@ export async function syncConfigToServer(options?: {
username: username || "", username: username || "",
displayName: response.name, displayName: response.name,
currentDomain: preservedCurrentDomain, currentDomain: preservedCurrentDomain,
agentId: response.agentId ?? currentUserInfo?.agentId,
} as AuthUserInfo); } as AuthUserInfo);
if (!suppressToast) { if (!suppressToast) {

View File

@@ -40,6 +40,7 @@ export interface AuthUserInfo {
userId?: string; userId?: string;
email?: string; email?: string;
currentDomain?: string; currentDomain?: string;
agentId?: number;
} }
export interface SetupState { export interface SetupState {

View File

@@ -0,0 +1,592 @@
.page {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
width: 100%;
height: 100%;
min-height: 0;
background: #f5f6fb;
color: #15171a;
}
.pageCenter {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 240px;
}
.threadRail {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 16px;
background: #ffffff;
border-right: 1px solid #dfe3e8;
}
.newButton {
width: 100%;
height: 38px;
border-radius: 8px;
background: #00746f;
border-color: #00746f;
font-weight: 600;
}
.search {
margin-top: 12px;
height: 38px;
border-radius: 8px;
background: #f4f7f7;
}
.recentLabel {
margin: 14px 0 8px;
font-size: 12px;
color: #8c959f;
}
.conversationList {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-height: 0;
overflow-y: auto;
}
.conversationItem {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
width: 100%;
min-height: 58px;
padding: 10px 12px;
color: #1f272e;
text-align: left;
background: transparent;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.conversationItem:hover {
background: #f0f6f5;
}
.conversationItem.active {
color: #006f6b;
background: #e4f5f3;
}
.conversationTitle {
width: 100%;
overflow: hidden;
font-size: 14px;
font-weight: 600;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversationTime {
color: #98a1a9;
font-size: 12px;
}
.listLoading,
.listEmpty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
}
.chatShell {
display: grid;
grid-template-rows: 56px minmax(0, 1fr) auto;
min-width: 0;
min-height: 0;
background: #f5f6fb;
}
.chatHeader {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 0;
padding: 0 24px;
background: #ffffff;
border-bottom: 1px solid #dfe3e8;
}
.breadcrumb {
min-width: 0;
overflow: hidden;
color: #68717b;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb span {
margin: 0 8px;
color: #a1a9b2;
}
.breadcrumb strong {
color: #15171a;
}
.modelBadge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
max-width: 240px;
height: 28px;
padding: 0 12px;
overflow: hidden;
color: #303840;
font-size: 13px;
background: #eef1f1;
border-radius: 6px;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelBadge span {
width: 6px;
height: 6px;
background: #00c896;
border-radius: 999px;
}
.chatBody {
min-height: 0;
padding: 28px 32px;
overflow-y: auto;
}
.emptyChat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100%;
text-align: center;
}
.emptyAvatar {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin-bottom: 16px;
font-size: 28px;
background: #dcecef;
border-radius: 16px;
}
.emptyChat h2 {
margin: 0;
color: #111318;
font-size: 24px;
line-height: 1.35;
}
.emptyChat p {
max-width: 520px;
margin: 12px 0 0;
color: #737c86;
font-size: 14px;
line-height: 1.6;
}
.suggestionList {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: min(560px, 100%);
margin-top: 22px;
}
.suggestionList button {
min-height: 38px;
padding: 8px 12px;
color: #1f272e;
text-align: left;
background: #ffffff;
border: 1px solid #e2e6ea;
border-radius: 8px;
cursor: pointer;
}
.suggestionList button:hover {
border-color: #00746f;
}
.messageRow {
display: flex;
gap: 10px;
width: 100%;
margin-bottom: 18px;
}
.userRow {
justify-content: flex-end;
}
.assistantRow {
justify-content: flex-start;
}
.assistantAvatar {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-top: 4px;
color: #00746f;
font-size: 12px;
font-weight: 700;
background: #dcecef;
border-radius: 9px;
}
.messageBubble {
max-width: min(720px, 76%);
padding: 12px 14px;
color: #1a2026;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 1px 0 rgb(21 23 26 / 4%);
}
.userRow .messageBubble {
color: #ffffff;
background: #00746f;
border-radius: 12px 12px 2px;
}
.messageText {
overflow-wrap: anywhere;
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
}
.thinkBlock {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
padding: 10px 12px;
color: #5d6873;
background: #f1f4f6;
border-left: 3px solid #b6c4c7;
border-radius: 8px;
}
.thinkBlock strong {
color: #38434d;
font-size: 12px;
}
.attachmentList {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.attachment {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
padding: 5px 8px;
overflow: hidden;
color: inherit;
font-size: 12px;
background: rgb(255 255 255 / 16%);
border: 1px solid rgb(255 255 255 / 20%);
border-radius: 7px;
text-decoration: none;
}
.assistantRow .attachment {
color: #38434d;
background: #f3f6f7;
border-color: #e2e7ea;
}
.attachment span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.processingList {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
}
.processingItem {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 5px 8px;
color: #56616c;
font-size: 12px;
background: #f4f7f8;
border-radius: 7px;
}
.processingName {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.processingStatus {
margin-left: auto;
color: #8a949e;
}
.messageStatus,
.messageError {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: #76818c;
font-size: 12px;
}
.messageError {
color: #c23b3b;
}
.messageActions {
display: flex;
gap: 4px;
margin-top: 8px;
}
.loadMoreWrap {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.composer {
padding: 14px 32px 12px;
background: #ffffff;
border-top: 1px solid #dfe3e8;
}
.chipRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 240px;
height: 28px;
padding: 0 10px;
overflow: hidden;
color: #1f5555;
font-size: 12px;
background: #e5f4f2;
border: 1px solid #c6e5e0;
border-radius: 999px;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
}
.inputRow {
display: grid;
grid-template-columns: minmax(0, 1fr) 72px;
gap: 12px;
align-items: end;
}
.textarea {
border-radius: 10px;
}
.sendButton {
height: 40px;
border-radius: 9px;
background: #00746f;
border-color: #00746f;
font-weight: 600;
}
.composerToolbar {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
margin-top: 8px;
color: #8c959f;
font-size: 12px;
}
.toolbarSpacer {
flex: 1;
}
.shortcut {
white-space: nowrap;
}
.modelSelect {
width: 150px;
}
.hiddenInput {
display: none;
}
.skillPanel,
.mcpPanel {
width: 320px;
max-height: 360px;
}
.skillList,
.mcpPanel {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
overflow-y: auto;
}
.skillItem,
.mcpItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 48px;
padding: 8px;
color: #202932;
text-align: left;
background: #ffffff;
border: 1px solid #e4e8eb;
border-radius: 8px;
cursor: pointer;
}
.skillItem:hover,
.mcpItem:hover,
.skillItem.selected,
.mcpItem.selected {
border-color: #00746f;
background: #edf8f6;
}
.skillIcon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
overflow: hidden;
color: #00746f;
background: #e5f4f2;
border-radius: 8px;
}
.skillIcon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.skillMeta,
.mcpItem span {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.skillMeta strong,
.mcpItem strong {
overflow: hidden;
font-size: 13px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.skillMeta small,
.mcpItem small {
overflow: hidden;
color: #7a858f;
font-size: 12px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 980px) {
.page {
grid-template-columns: 236px minmax(0, 1fr);
}
.threadRail {
padding: 12px;
}
.chatHeader,
.chatBody,
.composer {
padding-left: 18px;
padding-right: 18px;
}
.messageBubble {
max-width: 86%;
}
.shortcut {
display: none;
}
}
@media (max-width: 760px) {
.page {
grid-template-columns: 1fr;
}
.threadRail {
display: none;
}
.suggestionList {
grid-template-columns: 1fr;
}
}