feat: 重写原生AI对话页面
This commit is contained in:
@@ -42,6 +42,10 @@ import AboutPage from "./components/pages/AboutPage";
|
||||
import LogViewer from "./components/pages/LogViewer";
|
||||
import PermissionsPage from "./components/pages/PermissionsPage";
|
||||
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 AppSidebar, {
|
||||
NavKey,
|
||||
@@ -94,6 +98,10 @@ export function useI18nLang(): I18nContextValue {
|
||||
type TabKey =
|
||||
| "client"
|
||||
| "sessions"
|
||||
| "legacySessions"
|
||||
| "skills"
|
||||
| "mcpLibrary"
|
||||
| "workflow"
|
||||
| "mcp"
|
||||
| "settings"
|
||||
| "dependencies"
|
||||
@@ -989,8 +997,18 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "skills") {
|
||||
setActiveTab("skills");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "mcpLibrary") {
|
||||
setActiveTab("mcp");
|
||||
setActiveTab("mcpLibrary");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "workflow") {
|
||||
setActiveTab("workflow");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1003,6 +1021,12 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const handleLegacySelect = useCallback((key: LegacyTabKey) => {
|
||||
if (key === "sessions") {
|
||||
setActiveTab("legacySessions");
|
||||
setActiveNavKey("aiChat");
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveTab(key);
|
||||
const navByLegacyTab: Partial<Record<LegacyTabKey, NavKey>> = {
|
||||
client: "overview",
|
||||
@@ -1176,12 +1200,20 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === "sessions" && (
|
||||
<AiChatPage
|
||||
autoNew={sessionsAutoOpen}
|
||||
onAutoNewConsumed={() => setSessionsAutoOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "legacySessions" && (
|
||||
<SessionsPage
|
||||
autoOpen={sessionsAutoOpen}
|
||||
onAutoOpenConsumed={() => setSessionsAutoOpen(false)}
|
||||
autoOpen={false}
|
||||
onWebviewChange={setWebviewActions}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "skills" && <SkillsMarketplacePage />}
|
||||
{activeTab === "mcpLibrary" && <McpMarketplacePage />}
|
||||
{activeTab === "workflow" && <WorkflowMarketplacePage />}
|
||||
{activeTab === "mcp" && <MCPSettings />}
|
||||
{activeTab === "settings" && <SettingsPage />}
|
||||
{activeTab === "dependencies" && <DependenciesPage />}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -217,6 +217,8 @@ export interface ClientRegisterResponse {
|
||||
serverPort?: number;
|
||||
/** 登录态 token,用于 webview cookie 同步 */
|
||||
token?: string;
|
||||
/** 给当前客户端沙盒配置分配的智能体 ID,用于原生 AI 对话 */
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface AuthUserInfo {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
currentDomain?: string;
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
// ========== 存储辅助函数 ===
|
||||
@@ -335,6 +336,7 @@ export async function loginAndRegister(
|
||||
username,
|
||||
displayName: response.name,
|
||||
currentDomain: domain,
|
||||
agentId: response.agentId,
|
||||
});
|
||||
|
||||
await setOnlineStatus(response.online);
|
||||
@@ -503,6 +505,15 @@ export async function reRegisterClient(): Promise<ClientRegisterResponse | null>
|
||||
await setConfigKey(response.configKey);
|
||||
await setSavedKey(response.configKey, domain, username || undefined);
|
||||
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 同步)
|
||||
// 尝试立即同步,成功后清除;失败时保留给后续页面打开时重试
|
||||
@@ -667,6 +678,7 @@ export async function syncConfigToServer(options?: {
|
||||
username: username || "",
|
||||
displayName: response.name,
|
||||
currentDomain: preservedCurrentDomain,
|
||||
agentId: response.agentId ?? currentUserInfo?.agentId,
|
||||
} as AuthUserInfo);
|
||||
|
||||
if (!suppressToast) {
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface AuthUserInfo {
|
||||
userId?: string;
|
||||
email?: string;
|
||||
currentDomain?: string;
|
||||
agentId?: number;
|
||||
}
|
||||
|
||||
export interface SetupState {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user