代码提交
This commit is contained in:
1 parent
70da7c5649
commit
178572bc7f
79 files changed
+13456
-798
No files matched your search
@@ -0,0 +1,68 @@
|
||||
export type StreamingArtifactLanguage = "markdown" | "html" | "mermaid";
|
||||
|
||||
export function normalizeStreamingArtifactPath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
const first = normalized[0];
|
||||
const last = normalized.at(-1);
|
||||
if (
|
||||
normalized.length >= 2 &&
|
||||
((first === '"' && last === '"') ||
|
||||
(first === "'" && last === "'") ||
|
||||
(first === "`" && last === "`"))
|
||||
) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function hasCompleteArtifactExtension(path: string): boolean {
|
||||
const pathname = normalizeStreamingArtifactPath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
// JSON tool arguments commonly stream `report.m` before `report.md`.
|
||||
// Treating the partial suffix as final makes the sandbox permanently TEXT.
|
||||
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||
}
|
||||
|
||||
export function provisionalWriteFilePath(content: string): string {
|
||||
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||
return "未命名文件.html";
|
||||
}
|
||||
return "未命名文件.md";
|
||||
}
|
||||
|
||||
export function languageOfStreamingArtifact(
|
||||
path: string,
|
||||
): StreamingArtifactLanguage | null {
|
||||
const pathname = normalizeStreamingArtifactPath(path)
|
||||
.split(/[?#]/, 1)[0]
|
||||
?.toLowerCase();
|
||||
if (pathname?.endsWith(".md") || pathname?.endsWith(".markdown")) {
|
||||
return "markdown";
|
||||
}
|
||||
if (pathname?.endsWith(".html") || pathname?.endsWith(".htm")) {
|
||||
return "html";
|
||||
}
|
||||
if (pathname?.endsWith(".mmd") || pathname?.endsWith(".mermaid")) {
|
||||
return "mermaid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildWriteFileVirtualUrl({
|
||||
path,
|
||||
messageId,
|
||||
toolCallId,
|
||||
}: {
|
||||
path: string;
|
||||
messageId: string;
|
||||
toolCallId: string;
|
||||
}): string {
|
||||
const language = languageOfStreamingArtifact(path);
|
||||
const languageQuery = language ? `&language=${language}` : "";
|
||||
return new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}${languageQuery}`,
|
||||
).toString();
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
import type {
|
||||
DocumentRewriteStage,
|
||||
} from "@/open-canvas/hooks/useDocumentRewrite";
|
||||
import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite";
|
||||
|
||||
export type DocumentRewritePresentation = {
|
||||
id: string;
|
||||
threadId: string;
|
||||
source: {
|
||||
kind: "artifact" | "deep_research_report";
|
||||
id: string;
|
||||
path?: string;
|
||||
};
|
||||
fileName: string;
|
||||
instruction: string;
|
||||
style: string;
|
||||
modelName: string | null;
|
||||
stage: DocumentRewriteStage;
|
||||
originalCharCount: number;
|
||||
writtenCharCount: number;
|
||||
/** Short model-generated plan, streamed while it understands the request. */
|
||||
requirementsPlan: string;
|
||||
warnings: string[];
|
||||
summary: DocumentRewriteSummary | null;
|
||||
/** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */
|
||||
originalContent: string;
|
||||
rewrittenContent: string | null;
|
||||
versionId: string | null;
|
||||
/** Inverse snapshot created by a safe undo; enables a one-click reapply. */
|
||||
redoVersionId: string | null;
|
||||
revertedAt: string | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type CreatePresentationInput = Omit<
|
||||
DocumentRewritePresentation,
|
||||
| "id"
|
||||
| "stage"
|
||||
| "originalCharCount"
|
||||
| "writtenCharCount"
|
||||
| "requirementsPlan"
|
||||
| "warnings"
|
||||
| "summary"
|
||||
| "originalContent"
|
||||
| "rewrittenContent"
|
||||
| "versionId"
|
||||
| "redoVersionId"
|
||||
| "revertedAt"
|
||||
| "error"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
> & {
|
||||
originalContent?: string;
|
||||
rewrittenContent?: string | null;
|
||||
requirementsPlan?: string;
|
||||
};
|
||||
|
||||
type DocumentRewritePresentationContextValue = {
|
||||
create: (input: CreatePresentationInput) => string;
|
||||
update: (
|
||||
id: string,
|
||||
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
|
||||
) => void;
|
||||
forThread: (threadId: string) => DocumentRewritePresentation[];
|
||||
};
|
||||
|
||||
const DocumentRewritePresentationContext =
|
||||
createContext<DocumentRewritePresentationContextValue | null>(null);
|
||||
|
||||
function createId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return "drw-ui-" + crypto.randomUUID();
|
||||
}
|
||||
return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
export function DocumentRewritePresentationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [presentations, setPresentations] = useState<DocumentRewritePresentation[]>([]);
|
||||
|
||||
const create = useCallback((input: CreatePresentationInput) => {
|
||||
const id = createId();
|
||||
const now = new Date().toISOString();
|
||||
setPresentations((previous) => [
|
||||
...previous.slice(-29),
|
||||
{
|
||||
...input,
|
||||
id,
|
||||
stage: "snapshot",
|
||||
originalCharCount: 0,
|
||||
writtenCharCount: 0,
|
||||
requirementsPlan: input.requirementsPlan ?? "",
|
||||
warnings: [],
|
||||
summary: null,
|
||||
originalContent: input.originalContent ?? "",
|
||||
rewrittenContent: input.rewrittenContent ?? null,
|
||||
versionId: null,
|
||||
redoVersionId: null,
|
||||
revertedAt: null,
|
||||
error: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
(
|
||||
id: string,
|
||||
patch: Partial<Omit<DocumentRewritePresentation, "id" | "threadId" | "createdAt">>,
|
||||
) => {
|
||||
setPresentations((previous) => {
|
||||
const index = previous.findIndex((item) => item.id === id);
|
||||
if (index < 0) return previous;
|
||||
|
||||
const current = previous[index];
|
||||
const changed = Object.entries(patch).some(
|
||||
([key, value]) => current[key as keyof DocumentRewritePresentation] !== value,
|
||||
);
|
||||
// Do not create a new context value for an identical update. Stream
|
||||
// subscribers can legitimately receive an already-hydrated snapshot;
|
||||
// treating it as a state change makes effect-based synchronisation
|
||||
// vulnerable to update loops.
|
||||
if (!changed) return previous;
|
||||
|
||||
const next = [...previous];
|
||||
next[index] = { ...current, ...patch, updatedAt: new Date().toISOString() };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const value = useMemo<DocumentRewritePresentationContextValue>(
|
||||
() => ({
|
||||
create,
|
||||
update,
|
||||
forThread: (threadId) =>
|
||||
presentations.filter((item) => item.threadId === threadId),
|
||||
}),
|
||||
[create, presentations, update],
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentRewritePresentationContext.Provider value={value}>
|
||||
{children}
|
||||
</DocumentRewritePresentationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDocumentRewritePresentation() {
|
||||
return useContext(DocumentRewritePresentationContext);
|
||||
}
|
||||
|
||||
/** Browser event consumed by the open sandbox after an in-feed undo succeeds. */
|
||||
export const DOCUMENT_REWRITE_UNDONE_EVENT = "deerflow:document-rewrite-undone";
|
||||
@@ -22,6 +22,8 @@ interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistan
|
||||
|
||||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||||
|
||||
interface AssistantResearchApprovalGroup extends GenericMessageGroup<"assistant:research-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -32,6 +34,7 @@ type MessageGroup =
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSetupWritingApprovalGroup
|
||||
| AssistantResearchApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -54,7 +57,8 @@ export function groupMessages<T>(
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval"
|
||||
last.type !== "assistant:setup-writing-approval" &&
|
||||
last.type !== "assistant:research-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -114,6 +118,18 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "ai") {
|
||||
// Deep-research interaction card: virtual ai messages injected by the
|
||||
// workbench (additional_kwargs.setup_research_approval) render through
|
||||
// the host-provided slot. Writing phases use ordinary virtual tool calls
|
||||
// and completed summaries remain normal assistant messages.
|
||||
if (hasResearchApproval(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:research-approval",
|
||||
messages: [message],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (hasPresentFiles(message)) {
|
||||
groups.push({
|
||||
id: message.id,
|
||||
@@ -386,6 +402,14 @@ export function isSetupWritingApprovalToolMessage(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function hasResearchApproval(message: Message) {
|
||||
return (
|
||||
message.type === "ai" &&
|
||||
typeof message.additional_kwargs?.setup_research_approval === "object" &&
|
||||
message.additional_kwargs.setup_research_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
buildSentimentAgentHeaders,
|
||||
readSentimentAgentEndpoint,
|
||||
} from "./config";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体走的是外部 AG-UI 协议网关(POST,SSE 返回):
|
||||
* 请求体为 { threadId, runId, state, tools, context, forwardedProps, messages },
|
||||
* 返回为 `data: {"type": ...}` 的 SSE 事件流:
|
||||
* RUN_STARTED / THINKING_TEXT_MESSAGE_START|CONTENT|END /
|
||||
* TEXT_MESSAGE_START|CONTENT|END / RUN_FINISHED(另有 RUN_ERROR 等异常事件)。
|
||||
*/
|
||||
|
||||
/** AG-UI 协议消息(请求体里的 messages 数组元素)。 */
|
||||
export interface AgUiMessage {
|
||||
role: "user" | "assistant";
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface SentimentAgentRunHandlers {
|
||||
/** 推理(思考)文本开始:reasoning 消息流开始。 */
|
||||
onReasoningStart?: (messageId: string) => void;
|
||||
onReasoningDelta?: (messageId: string, delta: string) => void;
|
||||
/** 正文回答开始:answer 消息流开始。 */
|
||||
onAnswerStart?: (messageId: string) => void;
|
||||
onAnswerDelta?: (messageId: string, delta: string) => void;
|
||||
}
|
||||
|
||||
function textOfMessage(message: Message): string {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
if (!Array.isArray(message.content)) return "";
|
||||
return message.content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (!part || typeof part !== "object") return "";
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** 把本地会话消息转成 AG-UI 请求的历史消息(过滤空内容)。 */
|
||||
export function toAgUiMessages(messages: Message[]): AgUiMessage[] {
|
||||
return messages
|
||||
.filter((message) => message.type === "human" || message.type === "ai")
|
||||
.map((message) => ({
|
||||
role: message.type === "human" ? ("user" as const) : ("assistant" as const),
|
||||
id: message.id ?? "",
|
||||
content: textOfMessage(message),
|
||||
}))
|
||||
.filter((message) => message.content.trim().length > 0);
|
||||
}
|
||||
|
||||
type SseEvent = {
|
||||
event?: string;
|
||||
data: string;
|
||||
};
|
||||
|
||||
/** 按 SSE 规范切帧:事件以空行分隔,`data:` 行可能跨多行、帧可能跨 chunk。 */
|
||||
function parseSseFrames(buffer: string): { frames: SseEvent[]; rest: string } {
|
||||
const normalized = buffer.replace(/\r\n/g, "\n");
|
||||
const parts = normalized.split("\n\n");
|
||||
const rest = parts.pop() ?? "";
|
||||
const frames: SseEvent[] = [];
|
||||
for (const part of parts) {
|
||||
if (!part.trim()) continue;
|
||||
const dataLines: string[] = [];
|
||||
let eventName: string | undefined;
|
||||
for (const line of part.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice(5).replace(/^ /, ""));
|
||||
} else if (line.startsWith("event:")) {
|
||||
eventName = line.slice(6).replace(/^ /, "");
|
||||
}
|
||||
}
|
||||
if (dataLines.length > 0) {
|
||||
frames.push({ event: eventName, data: dataLines.join("\n") });
|
||||
}
|
||||
}
|
||||
return { frames, rest };
|
||||
}
|
||||
|
||||
export interface RunSentimentAgentOptions {
|
||||
threadId: string;
|
||||
runId: string;
|
||||
messages: AgUiMessage[];
|
||||
signal: AbortSignal;
|
||||
handlers: SentimentAgentRunHandlers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起一次舆情分析流式问答。resolve 于流正常结束(RUN_FINISHED 或连接关闭);
|
||||
* 网络错误 / 非 2xx / RUN_ERROR 事件都会 reject(AbortError 由调用方自行忽略)。
|
||||
*/
|
||||
export async function runSentimentAgent(
|
||||
options: RunSentimentAgentOptions,
|
||||
): Promise<void> {
|
||||
const { threadId, runId, messages, signal, handlers } = options;
|
||||
const { apiUrl } = readSentimentAgentEndpoint();
|
||||
if (!apiUrl) {
|
||||
throw new Error("未配置舆情分析接口地址(runtime-config.js 的 VITE_SENTIMENT_AGENT_API_URL)。");
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: buildSentimentAgentHeaders(),
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
threadId,
|
||||
runId,
|
||||
state: {},
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`舆情分析服务请求失败(HTTP ${response.status})。`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("舆情分析服务未返回流式响应。");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const dispatch = (payload: Record<string, unknown>) => {
|
||||
// 样例里思考事件使用 message_id,而正文事件使用 messageId;两种 AG-UI
|
||||
// 字段形式都兼容,避免后端升级/不同事件类型造成流式状态断开。
|
||||
const messageId = String(payload.message_id ?? payload.messageId ?? "");
|
||||
const delta = typeof payload.delta === "string" ? payload.delta : "";
|
||||
switch (payload.type) {
|
||||
case "THINKING_TEXT_MESSAGE_START":
|
||||
handlers.onReasoningStart?.(messageId);
|
||||
break;
|
||||
case "THINKING_TEXT_MESSAGE_CONTENT":
|
||||
handlers.onReasoningDelta?.(messageId, delta);
|
||||
break;
|
||||
case "TEXT_MESSAGE_START":
|
||||
handlers.onAnswerStart?.(messageId);
|
||||
break;
|
||||
case "TEXT_MESSAGE_CONTENT":
|
||||
handlers.onAnswerDelta?.(messageId, delta);
|
||||
break;
|
||||
case "RUN_ERROR": {
|
||||
const message =
|
||||
typeof payload.message === "string" && payload.message
|
||||
? payload.message
|
||||
: "舆情分析服务返回错误。";
|
||||
throw new Error(message);
|
||||
}
|
||||
default:
|
||||
// RUN_STARTED / *_END / RUN_FINISHED / TOOL_* 等事件不需要驱动 UI。
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const consumeFrames = (frames: SseEvent[]) => {
|
||||
for (const frame of frames) {
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(frame.data) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue; // 解析不了的脏帧直接跳过,不断流。
|
||||
}
|
||||
// RUN_ERROR 会在 dispatch 里抛出业务错误,正常往外传播。
|
||||
dispatch(payload);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// 某些网关会在最后一帧后直接关闭连接而不补 SSE 的空行分隔符。流已
|
||||
// 结束时剩余缓冲区就是完整帧,补上分隔符后仍要消费,避免丢最后几个字。
|
||||
buffer += decoder.decode();
|
||||
if (buffer.trim()) {
|
||||
const { frames } = parseSseFrames(`${buffer}\n\n`);
|
||||
consumeFrames(frames);
|
||||
}
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const { frames, rest } = parseSseFrames(buffer);
|
||||
buffer = rest;
|
||||
consumeFrames(frames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体(前端直连的虚拟智能体)的接入配置。
|
||||
* 接口地址与 Basic Auth 凭据都来自 public/runtime-config.js,部署后可直接改
|
||||
* dist/runtime-config.js 切换环境,无需重新打包。
|
||||
*/
|
||||
|
||||
/** 该虚拟智能体在路由里的固定 id(/agents/{id}/chats/...)。 */
|
||||
export const SENTIMENT_AGENT_ID = "sentiment-analysis";
|
||||
|
||||
export interface SentimentAgentEndpoint {
|
||||
apiUrl: string;
|
||||
authUsername: string;
|
||||
authPassword: string;
|
||||
}
|
||||
|
||||
export function readSentimentAgentEndpoint(): SentimentAgentEndpoint {
|
||||
return {
|
||||
apiUrl: readRuntimeEnv("VITE_SENTIMENT_AGENT_API_URL") ?? "",
|
||||
authUsername: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_USERNAME") ?? "",
|
||||
authPassword: readRuntimeEnv("VITE_SENTIMENT_AGENT_AUTH_PASSWORD") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isSentimentAgentConfigured(): boolean {
|
||||
return !!readSentimentAgentEndpoint().apiUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开放舆情分析虚拟智能体。未配置时保持展示,避免旧部署升级后入口意外消失;
|
||||
* 只有运行时配置显式写为 "false" 才关闭。
|
||||
*/
|
||||
export function isSentimentAgentVisible(): boolean {
|
||||
return readRuntimeEnv("VITE_SENTIMENT_AGENT_VISIBLE") !== "false";
|
||||
}
|
||||
|
||||
function toBase64(value: string): string {
|
||||
// 凭据按 UTF-8 字节做 Basic Auth base64,避免非 ASCII 字符导致 btoa 抛错。
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** 组装请求头:Basic Auth(用户名/密码都配置了才带 Authorization)。 */
|
||||
export function buildSentimentAgentHeaders(): Record<string, string> {
|
||||
const { authUsername, authPassword } = readSentimentAgentEndpoint();
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
};
|
||||
if (authUsername && authPassword) {
|
||||
headers.Authorization = `Basic ${toBase64(`${authUsername}:${authPassword}`)}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 舆情分析智能体的展示信息(名称 / 简介)。
|
||||
* 这是一个前端虚拟智能体(不落库后端 /api/agents),名称与简介保存在
|
||||
* localStorage,可在智能体管理页的编辑弹窗里随时修改。
|
||||
*/
|
||||
export interface SentimentAgentProfile {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "deerflow.sentiment-agent.profile";
|
||||
|
||||
/** 名称 / 简介 未自定义时的默认文案。 */
|
||||
export const DEFAULT_SENTIMENT_AGENT_PROFILE: SentimentAgentProfile = {
|
||||
name: "舆情分析",
|
||||
description:
|
||||
"面向舆情领域的态势问答助手:围绕指定话题或事件,自动梳理舆情脉络、研判发展态势、归纳各方观点与热点走向,输出结构化的分析结论,为舆情应对与决策提供参考。",
|
||||
};
|
||||
|
||||
export function readSentimentAgentProfile(): SentimentAgentProfile {
|
||||
if (typeof window === "undefined") return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const name =
|
||||
typeof record.name === "string" && record.name.trim()
|
||||
? record.name.trim()
|
||||
: DEFAULT_SENTIMENT_AGENT_PROFILE.name;
|
||||
const description =
|
||||
typeof record.description === "string"
|
||||
? record.description.trim()
|
||||
: DEFAULT_SENTIMENT_AGENT_PROFILE.description;
|
||||
return { name, description };
|
||||
} catch {
|
||||
return { ...DEFAULT_SENTIMENT_AGENT_PROFILE };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSentimentAgentProfile(profile: SentimentAgentProfile): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const name = profile.name.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.name;
|
||||
const description =
|
||||
profile.description.trim() || DEFAULT_SENTIMENT_AGENT_PROFILE.description;
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ name, description } satisfies SentimentAgentProfile),
|
||||
);
|
||||
// 编辑弹窗可能挂在管理页 / 对话页两处,广播一下让同页签的其他挂载点即时刷新。
|
||||
window.dispatchEvent(new CustomEvent("sentiment-agent-profile-changed"));
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { runSentimentAgent, toAgUiMessages } from "./api";
|
||||
|
||||
const STORAGE_PREFIX = "workspace.sentiment-agent.";
|
||||
|
||||
/**
|
||||
* 舆情分析智能体的前端会话状态:消息只存在当前标签页的 sessionStorage,
|
||||
* 不创建后端 thread、不进 LangGraph —— 所有问答都直连外部 AG-UI 网关。
|
||||
* 整体结构参照 use-single-round-rag 的「合成 LangGraph 消息 + 本地持久化」模式。
|
||||
*/
|
||||
export function useSentimentAgentChat(threadId: string) {
|
||||
const [messages, setMessages] = useState<Message[]>(() =>
|
||||
readStoredMessages(threadId),
|
||||
);
|
||||
const messagesRef = useRef(messages);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = readStoredMessages(threadId);
|
||||
messagesRef.current = stored;
|
||||
setMessages(stored);
|
||||
setError(null);
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
// 卸载 / 切会话时中断进行中的流。
|
||||
useEffect(() => () => controllerRef.current?.abort(), []);
|
||||
|
||||
const stop = useCallback(() => controllerRef.current?.abort(), []);
|
||||
|
||||
const commitMessages = useCallback(
|
||||
(updater: (current: Message[]) => Message[]) => {
|
||||
setMessages((current) => {
|
||||
const next = updater(current);
|
||||
messagesRef.current = next;
|
||||
writeStoredMessages(threadId, next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[threadId],
|
||||
);
|
||||
|
||||
const submit = useCallback(
|
||||
async ({ query }: { query: string }) => {
|
||||
if (isLoading) return;
|
||||
const question = query.trim();
|
||||
if (!question) return;
|
||||
|
||||
const assistantId = `sentiment-ai-${crypto.randomUUID()}`;
|
||||
const userMessage: Message = {
|
||||
id: `sentiment-human-${crypto.randomUUID()}`,
|
||||
type: "human",
|
||||
content: [{ type: "text", text: question }],
|
||||
additional_kwargs: {
|
||||
client_ts: new Date().toISOString(),
|
||||
sentiment_agent: true,
|
||||
},
|
||||
};
|
||||
const initialAssistant: Message = {
|
||||
id: assistantId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: { sentiment_agent: true },
|
||||
};
|
||||
|
||||
// AG-UI 历史 = 之前已完成的轮次 + 本轮提问。
|
||||
const history = [
|
||||
...toAgUiMessages(messagesRef.current),
|
||||
{ role: "user" as const, id: userMessage.id!, content: question },
|
||||
];
|
||||
|
||||
commitMessages((current) => [...current, userMessage, initialAssistant]);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
|
||||
// 思考 / 正文两个缓冲区 + rAF 合帧刷新:SSE 一轮几千个 delta,
|
||||
// 直接逐条 setState 会造成大量无效渲染。
|
||||
let reasoning = "";
|
||||
let answer = "";
|
||||
let flushScheduled = false;
|
||||
const flush = () => {
|
||||
flushScheduled = false;
|
||||
commitMessages((current) =>
|
||||
current.map((message) =>
|
||||
message.id === assistantId
|
||||
? ({
|
||||
...message,
|
||||
content: answer,
|
||||
additional_kwargs: {
|
||||
...message.additional_kwargs,
|
||||
sentiment_agent: true,
|
||||
reasoning_content: reasoning || null,
|
||||
},
|
||||
} as Message)
|
||||
: message,
|
||||
),
|
||||
);
|
||||
};
|
||||
const scheduleFlush = () => {
|
||||
if (flushScheduled) return;
|
||||
flushScheduled = true;
|
||||
requestAnimationFrame(flush);
|
||||
};
|
||||
|
||||
try {
|
||||
await runSentimentAgent({
|
||||
threadId,
|
||||
runId: `run-${crypto.randomUUID()}`,
|
||||
messages: history,
|
||||
signal: controller.signal,
|
||||
handlers: {
|
||||
onReasoningDelta: (_messageId, delta) => {
|
||||
reasoning += delta;
|
||||
scheduleFlush();
|
||||
},
|
||||
onAnswerDelta: (_messageId, delta) => {
|
||||
answer += delta;
|
||||
scheduleFlush();
|
||||
},
|
||||
},
|
||||
});
|
||||
flush();
|
||||
} catch (caught) {
|
||||
flush();
|
||||
if (!isAbortError(caught)) {
|
||||
const message =
|
||||
caught instanceof Error ? caught.message : "舆情分析问答失败。";
|
||||
setError(message);
|
||||
// 一个字都没回答出来时,把错误写进气泡,用户能看到失败原因。
|
||||
if (!answer) {
|
||||
commitMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.id === assistantId
|
||||
? ({ ...item, content: message } as Message)
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[commitMessages, isLoading, threadId],
|
||||
);
|
||||
|
||||
return { messages, isLoading, error, submit, stop };
|
||||
}
|
||||
|
||||
function storageKey(threadId: string) {
|
||||
return `${STORAGE_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
function readStoredMessages(threadId: string): Message[] {
|
||||
if (typeof window === "undefined" || !threadId) return [];
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(threadId));
|
||||
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? (parsed as Message[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredMessages(threadId: string, messages: Message[]) {
|
||||
if (typeof window === "undefined" || !threadId) return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
|
||||
} catch {
|
||||
// 配额异常不能打断进行中的回答。
|
||||
}
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
Reference in new issue
Block a user