Initial commit: GovAI 政务AI平台
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
useSSEStream,
|
||||
updateLastAssistantMessage,
|
||||
setStreamErrorMessage,
|
||||
} from "./use-sse-stream";
|
||||
export { useCopyToClipboard } from "./use-copy-clipboard";
|
||||
export { useScrollToBottom } from "./use-scroll-bottom";
|
||||
export { useFileExport } from "./use-file-export";
|
||||
export { useAppConfig, useSuggestedPrompts } from "./use-app-config";
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { App } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* 解析 app.app_config(可能是 JSON 字符串或对象)。
|
||||
* completion-ui / workflow-ui / agent-ui 共用。
|
||||
*/
|
||||
export function useAppConfig(app: App): Record<string, unknown> {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string")
|
||||
return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 app.suggested_prompts(可能是 JSON 字符串或数组)。
|
||||
* chatbot-ui / agent-ui 共用。
|
||||
*/
|
||||
export function useSuggestedPrompts(app: App): string[] {
|
||||
return useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
).current;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* 复制文本到剪贴板,显示 toast 提示。
|
||||
* chatbot-ui / agent-ui / completion-ui / doc-writer-ui / analysis-ui 共用。
|
||||
*/
|
||||
export function useCopyToClipboard(resetMs = 2000) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
(text: string, id?: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("已复制到剪贴板");
|
||||
if (id !== undefined) {
|
||||
setCopiedId(id);
|
||||
setTimeout(() => setCopiedId(null), resetMs);
|
||||
}
|
||||
},
|
||||
[resetMs],
|
||||
);
|
||||
|
||||
return { copy, copiedId };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* 触发浏览器文件下载。
|
||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 的导出功能共用。
|
||||
*/
|
||||
export function useFileExport() {
|
||||
const download = useCallback(
|
||||
(content: string, filename: string, successMsg = "已导出") => {
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success(successMsg);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { download };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* 自动滚动到容器底部。
|
||||
* 当 deps 变化时触发滚动(通常传入 messages 数组)。
|
||||
*/
|
||||
export function useScrollToBottom(deps: unknown[]) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
ref.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return ref;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import type { Message } from "@/lib/types";
|
||||
|
||||
interface SSEEvent {
|
||||
conversation_id?: string;
|
||||
answer?: string;
|
||||
}
|
||||
|
||||
interface UseSSEStreamOptions {
|
||||
onConversationId?: (id: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 SSE 流式响应的解析逻辑。
|
||||
* 所有应用类型(chatbot/completion/workflow/agent/doc-writer/analysis)共用同一套 SSE 协议。
|
||||
*/
|
||||
export function useSSEStream(options: UseSSEStreamOptions = {}) {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const processStream = useCallback(
|
||||
async (
|
||||
response: Response,
|
||||
onChunk: (accumulated: string) => void,
|
||||
) => {
|
||||
if (!response.ok) throw new Error("请求失败");
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法获取响应流");
|
||||
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const raw = line.slice(6);
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event: SSEEvent = JSON.parse(raw);
|
||||
if (event.conversation_id) {
|
||||
options.onConversationId?.(event.conversation_id);
|
||||
}
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
onChunk(accumulated);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed SSE data */
|
||||
}
|
||||
}
|
||||
}
|
||||
return accumulated;
|
||||
},
|
||||
[options],
|
||||
);
|
||||
|
||||
const startStream = useCallback(
|
||||
async (
|
||||
streamFn: (signal: AbortSignal) => Promise<Response>,
|
||||
onChunk: (accumulated: string) => void,
|
||||
): Promise<string> => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const response = await streamFn(controller.signal);
|
||||
return await processStream(response, onChunk);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
}
|
||||
},
|
||||
[processStream],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
return { startStream, abort, abortRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息列表中最后一条 assistant 消息的内容。
|
||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 共用此逻辑。
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
prev: Message[],
|
||||
content: string,
|
||||
): Message[] {
|
||||
return prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置最后一条 assistant 消息为错误提示(仅当内容为空时)。
|
||||
*/
|
||||
export function setStreamErrorMessage(
|
||||
prev: Message[],
|
||||
errorText = "抱歉,系统处理异常,请稍后重试。",
|
||||
): Message[] {
|
||||
return prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: errorText }
|
||||
: m,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user