Initial commit: GovAI 政务AI平台

This commit is contained in:
freedakgmail
2026-06-15 23:48:37 +08:00
commit 0f490f72a9
245 changed files with 51669 additions and 0 deletions
+9
View File
@@ -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";
+38
View File
@@ -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;
}
+26
View File
@@ -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 };
}
+31
View File
@@ -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 };
}
+22
View File
@@ -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;
}
+116
View File
@@ -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,
);
}