Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe365429d |
No files matched your search
@@ -112,6 +112,9 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// A 系统跳转地址:人物、组织、事件等列表项打开链接使用。
|
||||
VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain",
|
||||
|
||||
// B 系统(XWT检索)头像代理;按部署环境设置为浏览器可访问的地址。
|
||||
VITE_AIPI_MINIO_PROXY_URL: "http://minio-proxy.b.sit.4.cn/facebook-proxy",
|
||||
|
||||
// B 系统 knowes 根地址。
|
||||
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
|
||||
|
||||
|
||||
@@ -599,8 +599,8 @@ const DocumentUploadPopupContent: FC<{
|
||||
};
|
||||
|
||||
const yUserId = getYUserId();
|
||||
const userToken = getStoredAuth()?.access_token;
|
||||
const iframeTokenParam = userToken ? `&token=${encodeURIComponent(userToken)}` : "";
|
||||
const notebookToken = getNotebookToken();
|
||||
const iframeTokenParam = notebookToken ? `&token=${encodeURIComponent(notebookToken)}` : "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -188,9 +188,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
const [directionType, setDirectionType] = useState<string>('TW');
|
||||
const directionValue = useRef<string>('TW');
|
||||
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || 'https://ch1.b.uat.4.cn/consumer';
|
||||
const backendUrl = typeof window !== 'undefined'
|
||||
? (readRuntimeEnv("VITE_BACKEND_BASE_URL") || 'http://localhost:8001')
|
||||
: 'http://localhost:8001';
|
||||
const minioProxyUrl = readRuntimeEnv("VITE_AIPI_MINIO_PROXY_URL") || 'http://minio-proxy.b.sit.4.cn/facebook-proxy';
|
||||
const actorBaseUrl = readRuntimeEnv("VITE_AIPI_B2ACTOR_URL") || 'http://ty.b.uat.4.cn';
|
||||
const avatarUrl = (profileImage: string | null) => profileImage
|
||||
? `${minioProxyUrl}?${new URLSearchParams({ url: profileImage, is_avatar: 'true' })}`
|
||||
: `${actorBaseUrl}/manage/img/default.png`;
|
||||
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || 'http://localhost:8001';
|
||||
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
@@ -288,9 +291,7 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
setIsSearching(false);
|
||||
const result: UserDataItem[] = data.data.list;
|
||||
result.forEach((item) => {
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.profile_image = avatarUrl(item.profile_image);
|
||||
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
|
||||
item.title = item.user_name || item.screen_name || item.user_id;
|
||||
item.content = item.agentInfo['账号描述'];
|
||||
@@ -330,9 +331,7 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
|
||||
const result: UserDataItem[] = data.data.data;
|
||||
result.forEach((item) => {
|
||||
item.hobbies = item.groupDiscussTags || [];
|
||||
item.profile_image = item.profile_image
|
||||
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||
item.profile_image = avatarUrl(item.profile_image);
|
||||
item.classification = `${item.political_stand}`;
|
||||
item.title = item.group_name || item.groupName;
|
||||
if (item.hx_info?.['用户画像']) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export function ContextWindowSettingsPage() {
|
||||
const update = useUpdateConversationContextSettings();
|
||||
const enabled = data?.recent_turn_window_enabled ?? false;
|
||||
const recentTurns = data?.recent_turns ?? 3;
|
||||
const useQuestionAsTitle = data?.use_question_as_title ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
@@ -30,6 +31,19 @@ export function ContextWindowSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuestionTitleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ use_question_as_title: checked });
|
||||
toast.success(
|
||||
checked
|
||||
? "已开启:对话标题直接使用首个问题"
|
||||
: "已关闭:恢复回答后的标题生成",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
@@ -62,6 +76,25 @@ export function ContextWindowSettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">标题直接使用首个问题</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
开启后,首个问题会立即成为最终标题;回答结束后不再额外调用模型生成标题,也不轮询标题状态,避免影响下一轮提问。关闭后恢复原来的回答后标题生成方式。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={useQuestionAsTitle}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleQuestionTitleChange}
|
||||
aria-label="标题直接使用首个问题"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-destructive text-sm">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Clock3Icon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
|
||||
import { Clock3Icon, FileOutputIcon, ListOrderedIcon, Loader2Icon, UsersIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useRoundtableRoomLimitSettings,
|
||||
useUpdateRoundtableRoomLimitSettings,
|
||||
useWriteFileCompatibilitySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { loadRoundtableRoomSummary } from "@/roundtable-planning/api/rooms";
|
||||
|
||||
@@ -16,7 +17,10 @@ export function RoundtableRoomSettingsPage() {
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useRoundtableRoomLimitSettings();
|
||||
const update = useUpdateRoundtableRoomLimitSettings();
|
||||
const globalFileStream = useWriteFileCompatibilitySettings();
|
||||
const enabled = data?.enabled ?? true;
|
||||
const fileStreamEnabled = data?.write_file_compatibility_enabled ?? false;
|
||||
const globalFileStreamEnabled = globalFileStream.data?.enabled ?? false;
|
||||
const [maxRooms, setMaxRooms] = useState(String(data?.max_rooms ?? 3));
|
||||
const [maxHoldMinutes, setMaxHoldMinutes] = useState(
|
||||
String(data?.max_hold_minutes ?? 30),
|
||||
@@ -62,6 +66,7 @@ export function RoundtableRoomSettingsPage() {
|
||||
max_rooms?: number;
|
||||
max_hold_minutes?: number;
|
||||
max_cycles?: number;
|
||||
write_file_compatibility_enabled?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await update.mutateAsync(next);
|
||||
@@ -215,6 +220,37 @@ export function RoundtableRoomSettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<FileOutputIcon className="size-4" />
|
||||
会商文件流式兼容
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
会商生成文件并打开沙箱时使用兼容写入。全局「文件流式兼容」开着时,会商自动使用;全局关着时,打开本开关后只有会商使用,普通对话保持原来的写入方式。
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{globalFileStreamEnabled
|
||||
? "当前会商会使用兼容写入,因为全局开关已开启。"
|
||||
: fileStreamEnabled
|
||||
? "当前只有会商使用兼容写入。"
|
||||
: "当前会商使用各模型原来的写入方式。"}
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={fileStreamEnabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
void save({ write_file_compatibility_enabled: checked })
|
||||
}
|
||||
aria-label="会商启用文件流式兼容"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前全站占用:{activeCount == null ? "暂时无法读取" : `${activeCount} / ${data?.max_rooms ?? maxRooms}`}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ModelWriteFileTransport =
|
||||
| "native"
|
||||
| "buffered_gateway"
|
||||
| "artifact_renderer"
|
||||
| "text_bridge";
|
||||
|
||||
export type ModelWriteFileTransportToggle = "native" | "buffered_gateway";
|
||||
|
||||
export interface ManagedModel {
|
||||
name: string;
|
||||
display_name: string | null;
|
||||
@@ -23,6 +31,7 @@ export interface ManagedModel {
|
||||
thinking: Record<string, unknown> | null;
|
||||
use_responses_api: boolean | null;
|
||||
output_version: string | null;
|
||||
write_file_transport: ModelWriteFileTransport;
|
||||
extra_settings: Record<string, unknown>;
|
||||
api_key_configured: boolean;
|
||||
api_key_source: string | null;
|
||||
@@ -120,6 +129,22 @@ export async function updateManagedModelReasoningEffortCapability(
|
||||
return response.json() as Promise<ManagedModel>;
|
||||
}
|
||||
|
||||
export async function updateManagedModelWriteFileTransport(
|
||||
name: string,
|
||||
writeFileTransport: ModelWriteFileTransportToggle,
|
||||
): Promise<ManagedModel> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/models/write-file-transport/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ write_file_transport: writeFileTransport }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) return readError(response, "保存文件流式兼容设置失败");
|
||||
return response.json() as Promise<ManagedModel>;
|
||||
}
|
||||
|
||||
export async function deleteManagedModel(name: string): Promise<void> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
testManagedModel,
|
||||
updateManagedModel,
|
||||
updateManagedModelReasoningEffortCapability,
|
||||
updateManagedModelWriteFileTransport,
|
||||
type ModelSavePayload,
|
||||
type ModelWriteFileTransportToggle,
|
||||
} from "./api";
|
||||
|
||||
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
|
||||
@@ -66,6 +68,25 @@ export function useUpdateManagedModelReasoningEffortCapability() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateManagedModelWriteFileTransport() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
name,
|
||||
writeFileTransport,
|
||||
}: {
|
||||
name: string;
|
||||
writeFileTransport: ModelWriteFileTransportToggle;
|
||||
}) => updateManagedModelWriteFileTransport(name, writeFileTransport),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteManagedModel() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -728,10 +728,12 @@ export interface MemoryRuntimeSettings {
|
||||
export interface ConversationContextSettings {
|
||||
recent_turn_window_enabled: boolean;
|
||||
recent_turns: number;
|
||||
use_question_as_title: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationContextUpdate {
|
||||
recent_turn_window_enabled: boolean;
|
||||
recent_turn_window_enabled?: boolean;
|
||||
use_question_as_title?: boolean;
|
||||
}
|
||||
|
||||
export interface RoundtableRoomLimitSettings {
|
||||
@@ -739,6 +741,7 @@ export interface RoundtableRoomLimitSettings {
|
||||
max_rooms: number;
|
||||
max_hold_minutes: number;
|
||||
max_cycles: number;
|
||||
write_file_compatibility_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RoundtableRoomLimitUpdate {
|
||||
@@ -746,6 +749,7 @@ export interface RoundtableRoomLimitUpdate {
|
||||
max_rooms?: number;
|
||||
max_hold_minutes?: number;
|
||||
max_cycles?: number;
|
||||
write_file_compatibility_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OrdinaryKnowledgeRetrievalSettings {
|
||||
|
||||
@@ -1340,11 +1340,11 @@ export function useThreadStream({
|
||||
runId = inflight?.run_id ?? null;
|
||||
if (runId) {
|
||||
activeRunIdRef.current = runId;
|
||||
} else {
|
||||
const lastTerminal = runs.find((item) =>
|
||||
TERMINAL_RUN_STATUSES.has(item.status),
|
||||
);
|
||||
if (lastTerminal) terminalStatus = lastTerminal.status;
|
||||
} else {
|
||||
const lastTerminal = runs.find((item) =>
|
||||
TERMINAL_RUN_STATUSES.has(item.status),
|
||||
);
|
||||
if (lastTerminal) terminalStatus = lastTerminal.status;
|
||||
}
|
||||
} catch {
|
||||
if (!isCurrentRecovery()) return "unknown";
|
||||
@@ -1357,45 +1357,45 @@ export function useThreadStream({
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalStatus) {
|
||||
try {
|
||||
return (await applyTerminalState(terminalStatus))
|
||||
? "terminal"
|
||||
: "unknown";
|
||||
} catch {
|
||||
if (isCurrentRecovery()) {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalStatus) {
|
||||
try {
|
||||
return (await applyTerminalState(terminalStatus))
|
||||
? "terminal"
|
||||
: "unknown";
|
||||
} catch {
|
||||
if (isCurrentRecovery()) {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
// The backend confirms there is no in-flight run, but an SDK reader
|
||||
// can remain locally open after a proxy/SSE disconnect. Detach that
|
||||
// reader only after removing its reconnect marker so stop() cannot
|
||||
// accidentally cancel a server run.
|
||||
try {
|
||||
const readerStopped = await stopRecoveredRunReader({
|
||||
isCurrent: isCurrentRecovery,
|
||||
clearReconnect: () => clearActiveRunId(tid),
|
||||
stop: () => threadStreamRef.current?.stop(),
|
||||
});
|
||||
if (!readerStopped) return "unknown";
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
backgroundHistory.confirmTerminal();
|
||||
void backgroundHistory.refresh(false);
|
||||
return "terminal";
|
||||
} catch {
|
||||
if (isCurrentRecovery()) {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
// The backend confirms there is no in-flight run, but an SDK reader
|
||||
// can remain locally open after a proxy/SSE disconnect. Detach that
|
||||
// reader only after removing its reconnect marker so stop() cannot
|
||||
// accidentally cancel a server run.
|
||||
try {
|
||||
const readerStopped = await stopRecoveredRunReader({
|
||||
isCurrent: isCurrentRecovery,
|
||||
clearReconnect: () => clearActiveRunId(tid),
|
||||
stop: () => threadStreamRef.current?.stop(),
|
||||
});
|
||||
if (!readerStopped) return "unknown";
|
||||
activeRunIdRef.current = null;
|
||||
setStreamHoldOpen(false);
|
||||
streamReconnectAttemptsRef.current = 0;
|
||||
backgroundHistory.confirmTerminal();
|
||||
void backgroundHistory.refresh(false);
|
||||
return "terminal";
|
||||
} catch {
|
||||
if (isCurrentRecovery()) {
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
setStreamHoldOpen(true);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// A second UI failure can pause recovery while /runs is in flight.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CircleAlertIcon,
|
||||
CpuIcon,
|
||||
EyeIcon,
|
||||
FileOutputIcon,
|
||||
GripVerticalIcon,
|
||||
KeyRoundIcon,
|
||||
LoaderCircleIcon,
|
||||
@@ -54,7 +55,9 @@ import {
|
||||
useSaveManagedModel,
|
||||
useTestManagedModel,
|
||||
useUpdateManagedModelReasoningEffortCapability,
|
||||
useUpdateManagedModelWriteFileTransport,
|
||||
} from "@/core/model-management";
|
||||
import { useWriteFileCompatibilitySettings } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -197,25 +200,44 @@ function SortableModelCard({
|
||||
dragEnabled,
|
||||
reasoningCapabilityDisabled,
|
||||
reasoningCapabilityPending,
|
||||
globalWriteFileCompatEnabled,
|
||||
writeFileCompatPending,
|
||||
onConfigure,
|
||||
onDelete,
|
||||
onReasoningCapabilityChange,
|
||||
onReasoningEffortChange,
|
||||
onWriteFileCompatChange,
|
||||
}: {
|
||||
model: ManagedModel;
|
||||
isDefault: boolean;
|
||||
dragEnabled: boolean;
|
||||
reasoningCapabilityDisabled: boolean;
|
||||
reasoningCapabilityPending: boolean;
|
||||
globalWriteFileCompatEnabled: boolean;
|
||||
writeFileCompatPending: boolean;
|
||||
onConfigure: () => void;
|
||||
onDelete: () => void;
|
||||
onReasoningCapabilityChange: (enabled: boolean) => void;
|
||||
onReasoningEffortChange: (effort: "low" | "medium" | "xhigh") => void;
|
||||
onWriteFileCompatChange: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: model.name,
|
||||
disabled: !dragEnabled,
|
||||
});
|
||||
const writeFileCompatEligible =
|
||||
(model.use === "langchain_openai:ChatOpenAI" ||
|
||||
model.use === "deerflow.models.patched_openai:PatchedChatOpenAI") &&
|
||||
!model.use_responses_api;
|
||||
const perModelWriteFileCompat = model.write_file_transport === "buffered_gateway";
|
||||
const writeFileCompatActive = perModelWriteFileCompat || globalWriteFileCompatEnabled;
|
||||
const writeFileCompatHint = globalWriteFileCompatEnabled
|
||||
? "全局文件流式兼容已开启,所有兼容模型均已强制启用;关闭全局开关后恢复此处的单独设置。"
|
||||
: !writeFileCompatEligible
|
||||
? "仅支持 OpenAI 兼容接口(Chat Completions)模型。"
|
||||
: perModelWriteFileCompat
|
||||
? "该模型已启用:文件正文改由普通文本流生成,适配缓存长工具参数的网关。"
|
||||
: "针对会缓存长工具参数的内网模型网关;开启后文件正文改由普通文本流生成,对问答与文件卡片完全透明。";
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -288,6 +310,9 @@ function SortableModelCard({
|
||||
{model.supports_vision && (
|
||||
<ModelCapabilityBadge icon={EyeIcon} label="视觉" className="bg-amber-500/10 text-amber-700 dark:text-amber-300" />
|
||||
)}
|
||||
{writeFileCompatActive && writeFileCompatEligible && (
|
||||
<ModelCapabilityBadge icon={FileOutputIcon} label="文件流式兼容" className="bg-sky-500/10 text-sky-700 dark:text-sky-300" />
|
||||
)}
|
||||
{model.supports_reasoning_effort && (
|
||||
<ModelCapabilityBadge label={`全局默认:${model.default_reasoning_effort === "low" ? "低" : model.default_reasoning_effort === "medium" ? "中" : "Ultra"}`} className="bg-muted text-muted-foreground" />
|
||||
)}
|
||||
@@ -332,6 +357,22 @@ function SortableModelCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-between gap-4 rounded-lg border border-border px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||
<span>文件流式兼容</span>
|
||||
<Tag theme="default" variant="light" size="small">config.yaml</Tag>
|
||||
{globalWriteFileCompatEnabled && <Tag theme="primary" variant="light" size="small">全局已开启</Tag>}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{writeFileCompatHint}</p>
|
||||
</div>
|
||||
<Switch
|
||||
value={writeFileCompatActive}
|
||||
loading={writeFileCompatPending}
|
||||
disabled={globalWriteFileCompatEnabled || !writeFileCompatEligible}
|
||||
onChange={(value) => onWriteFileCompatChange(Boolean(value))}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -446,6 +487,9 @@ export default function ModelManagementPage() {
|
||||
const reorder = useReorderManagedModels();
|
||||
const updateReasoningCapability =
|
||||
useUpdateManagedModelReasoningEffortCapability();
|
||||
const updateWriteFileTransport = useUpdateManagedModelWriteFileTransport();
|
||||
const { data: writeFileCompatSettings } = useWriteFileCompatibilitySettings();
|
||||
const globalWriteFileCompatEnabled = writeFileCompatSettings?.enabled ?? false;
|
||||
const sourceModels = data?.models ?? [];
|
||||
const orderedModels = useMemo(() => {
|
||||
if (!pendingOrder || pendingOrder.length !== sourceModels.length) return sourceModels;
|
||||
@@ -511,6 +555,26 @@ export default function ModelManagementPage() {
|
||||
showGlobalMessage("error", error instanceof Error ? error.message : "保存全局思考强度失败");
|
||||
}
|
||||
};
|
||||
const handleWriteFileCompatChange = async (
|
||||
model: ManagedModel,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
try {
|
||||
await updateWriteFileTransport.mutateAsync({
|
||||
name: model.name,
|
||||
writeFileTransport: enabled ? "buffered_gateway" : "native",
|
||||
});
|
||||
showGlobalMessage(
|
||||
"success",
|
||||
`模型“${model.display_name || model.name}”的文件流式兼容已${enabled ? "开启" : "关闭"},从下一次发起的问答开始生效。`,
|
||||
);
|
||||
} catch (error) {
|
||||
showGlobalMessage(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "保存文件流式兼容设置失败",
|
||||
);
|
||||
}
|
||||
};
|
||||
const dragEnabled = filter === "all" && !search.trim() && !reorder.isPending;
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
@@ -637,6 +701,11 @@ export default function ModelManagementPage() {
|
||||
updateReasoningCapability.isPending &&
|
||||
updateReasoningCapability.variables?.name === model.name
|
||||
}
|
||||
globalWriteFileCompatEnabled={globalWriteFileCompatEnabled}
|
||||
writeFileCompatPending={
|
||||
updateWriteFileTransport.isPending &&
|
||||
updateWriteFileTransport.variables?.name === model.name
|
||||
}
|
||||
onConfigure={() => setEditor(model)}
|
||||
onDelete={() => setDeleteTarget(model)}
|
||||
onReasoningCapabilityChange={(enabled) =>
|
||||
@@ -645,6 +714,9 @@ export default function ModelManagementPage() {
|
||||
onReasoningEffortChange={(effort) =>
|
||||
void handleReasoningEffortChange(model, effort)
|
||||
}
|
||||
onWriteFileCompatChange={(enabled) =>
|
||||
void handleWriteFileCompatChange(model, enabled)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1489,7 +1489,7 @@ scheduler runs once per deployment. Per run:
|
||||
- **可检测的模型失败**:`LLMErrorHandlingMiddleware` 在同一模型重试 3 次后**不抛异常**,而是返回兜底 `AIMessage` —— 现在给它打 `additional_kwargs[LLM_ERROR_MARKER="deerflow_llm_error"]=reason`(model_dump 保留,经 checkpointer 序列化仍在)。`is_llm_error_message(msg)` 据标记(回退按已知兜底文案正文)识别,返回 reason。
|
||||
- **候选模型链**:保留真正模型故障后的自动换模,但“仅有思考 / 暂时无正文 / 空交付”不再判作模型故障,绝不据此切换用户选择的模型。`app/gateway/roundtable_model_fallback.py::fallback_model_chain(current)` 按 `config.yaml models[]` 顺序给出 `[当前模型, …其余]`(去重保序)。`reason_text(reason)` 翻译成简短中文。总开关 env `ROUNDTABLE_MODEL_FALLBACK`(默认开,`0/false/off` 关);`ROUNDTABLE_MODEL_FALLBACK_MAX`(0=试完全部)。
|
||||
- **触发换模型的情形**:① 单轮 run 抛异常(start_run 失败 / idle 卡死 → `timeout`,其余 `exception`);② 兜底错误文案(`is_llm_error_message`)。交付无效、仅有思考、空串以及普通总控轮既无正文又无派活**均不触发换模**;唯一例外是无工具的最终收口请求已经完整结束仍无正文,此时结果客观不可交付,才继续自动换下一个模型。席位仍只认剥掉 `<think>` 后的可见正文(`deerflow.agents.roundtable_orchestrator.delivery`:`visible_seat_text` / `classify_seat_delivery`):前台 `_special_run` 对无效交付 yield `status:"invalid_delivery"` 且不广播;`GET /last-ai-message` 回 `{delivered, content, length, invalid_reason}`(思考-only 标 `thinking_only`,总控席位清单标 ⚠️)。后台引擎只把可见正文写入 `deliveries`,无效席位继续走既有未交付处理,不切换模型。Step3 单例(report/summary/dashboard/ingest/结构化)的产物是落盘文件,同样只对真模型错误换模型。
|
||||
- **Step2 总控工具与可配置收口轮数**:普通总控使用真正白名单,只允许 `agent_orchestration` / `ask_clarification`;`write_file`、`present_files`、技能目录与文件/命令工具均不可见。`system_settings.json -> roundtable_room_limit.max_cycles`(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式 `finalize_only`:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。
|
||||
- **Step2 总控工具与可配置收口轮数**:普通总控使用真正白名单,只允许 `agent_orchestration` / `ask_clarification`;`write_file`、`present_files`、技能目录与文件/命令工具均不可见。`system_settings.json -> roundtable_room_limit.max_cycles`(默认 8,范围 2..100;管理员在设置弹窗「会商并发与收口」中修改)控制 recommend 模式到第几轮切换为显式 `finalize_only`:空工具白名单、注入已有有效席位全文、要求直接输出最终总结(失败席位如实标缺口),不能表现为“已停止”。一次会商启动时锁定当时值,运行中修改从下一次研讨生效;前台实时会商与后台作业读取同一设置。同一页的「会商文件流式兼容」(`write_file_compatibility_enabled`,默认关) 只作用于会商总控、席位、第三步和后台作业:全局文件流式兼容开着时会商一并使用;全局关着而本开关开着时,只有这些会商 run 改走 `buffered_gateway`。普通对话不读这个开关。chain / dag 的最终总结仍在既定席位完成后走同一纯正文路径;LangGraph 单轮 recursion limit 仍为 1000。
|
||||
- **后台**:`InProcessRoundtableGateway._run_turn_with_fallback(...)` 包住 `_run_agent_turn`,逐候选模型试;换模型落 `model_switch` 诊断、全失败落 `model_fallback_exhausted`。`run_leader/run_seat/run_report/run_summary/run_dashboard_data` 全走它(report/summary/dashboard 的产物自愈重试保留,并记住已切到的可用模型用于后续续写轮)。各 Turn/Result 带回 `switches: list[ModelSwitch]`,引擎 `_emit_switches` → `JobProgressWriter.model_switch(...)` 往研讨时间线插一条 `role="system"` 系统提示气泡。
|
||||
- **前台**:`_special_run`/`_leader_run` stream 收尾后仅检测明确的模型错误标记;命中才 yield `{status:"model_switch", role, agent_name, failed_model, next_model, reason}` 帧并用下一个模型重新流式,思考-only/空正文不进入该分支;终态帧带 `model_used`。结构化抽取 `flow_extract.py` 的 round1 `model.astream` 也包了同款换模型循环(emit `event:"model_switch"` 帧)。
|
||||
- **Step2 单席位隔离**:`engine._run_recommend/_run_chain/_run_dag` 的每个席位执行包 try/except —— 一个席位彻底失败(落 `seat_failed` 诊断 + 置空交付)不拖垮整批/整链/整个作业。
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.gateway.routers._roundtable_seed import (
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from app.gateway.routers.multi_agent import _roundtable_file_stream_context
|
||||
from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD
|
||||
from deerflow.agents.roundtable_orchestrator.delivery import classify_seat_delivery, visible_seat_text
|
||||
from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
|
||||
@@ -365,6 +366,7 @@ async def _run_agent_turn(
|
||||
# 被上轮会话 memory.json 带偏(臆造席位 / 直接干活)。详见该处注释。
|
||||
"memory_injection_enabled": False,
|
||||
"allowed_tools": allowed_tools,
|
||||
**_roundtable_file_stream_context(),
|
||||
},
|
||||
excluded_tools=excluded_tools,
|
||||
skill_stop_names=skill_stop_names,
|
||||
|
||||
@@ -224,6 +224,12 @@ class ReasoningEffortCapabilityRequest(BaseModel):
|
||||
default_reasoning_effort: Literal["low", "medium", "xhigh"] | None = None
|
||||
|
||||
|
||||
class WriteFileTransportToggleRequest(BaseModel):
|
||||
"""Per-model 文件流式兼容 switch: off keeps native, on selects buffered_gateway."""
|
||||
|
||||
write_file_transport: Literal["native", "buffered_gateway"]
|
||||
|
||||
|
||||
class ModelOrderRequest(BaseModel):
|
||||
"""The complete ordered set of model stable names."""
|
||||
|
||||
@@ -313,6 +319,39 @@ def _validate_reasoning_capability(model: dict[str, Any], enabled: bool) -> None
|
||||
)
|
||||
|
||||
|
||||
# ``write_file_transport=buffered_gateway`` is implemented only by the patched
|
||||
# OpenAI-compatible chat-completions classes (see deerflow.models.factory);
|
||||
# every other driver is rejected there at construction time, so mirror the
|
||||
# same constraint here with a clearer message.
|
||||
_BUFFERED_GATEWAY_COMPATIBLE_USES = frozenset(
|
||||
{
|
||||
"langchain_openai:ChatOpenAI",
|
||||
"deerflow.models.patched_openai:PatchedChatOpenAI",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _validate_write_file_transport(model: dict[str, Any], transport: str) -> None:
|
||||
if transport != WriteFileTransport.BUFFERED_GATEWAY.value:
|
||||
return
|
||||
if str(model.get("use") or "") not in _BUFFERED_GATEWAY_COMPATIBLE_USES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"write_file compatibility requires an OpenAI-compatible "
|
||||
"langchain_openai:ChatOpenAI model"
|
||||
),
|
||||
)
|
||||
if bool(model.get("use_responses_api")):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"write_file compatibility requires the chat-completions API, "
|
||||
"not use_responses_api"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _restore_capability(
|
||||
store: ModelCapabilityStore,
|
||||
model_name: str,
|
||||
@@ -672,6 +711,48 @@ async def update_reasoning_effort_capability(
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/write-file-transport/{model_name:path}",
|
||||
response_model=ModelAdminItem,
|
||||
)
|
||||
async def update_write_file_transport(
|
||||
model_name: str,
|
||||
body: WriteFileTransportToggleRequest,
|
||||
request: Request,
|
||||
) -> ModelAdminItem:
|
||||
"""Toggle one model's buffered-gateway write_file compatibility in config.yaml.
|
||||
|
||||
The global system-settings switch keeps overriding every compatible model
|
||||
while it is enabled; this per-model value becomes effective as soon as the
|
||||
global switch is turned off.
|
||||
"""
|
||||
|
||||
await _require_admin(request)
|
||||
async with _MUTATION_LOCK:
|
||||
path = _resolve_config_path()
|
||||
raw_before, _, models = await asyncio.to_thread(_read_config_data, path)
|
||||
index = next((i for i, item in enumerate(models) if str(item.get("name")) == model_name), None)
|
||||
if index is None:
|
||||
raise HTTPException(status_code=404, detail=f"Model '{model_name}' was not found")
|
||||
updated = copy.deepcopy(models[index])
|
||||
_validate_write_file_transport(updated, body.write_file_transport)
|
||||
updated["write_file_transport"] = body.write_file_transport
|
||||
try:
|
||||
ModelConfig.model_validate(updated)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid model configuration: {exc}") from exc
|
||||
next_models = [*models]
|
||||
next_models[index] = updated
|
||||
await _save_models(request, raw_before, next_models)
|
||||
capabilities = await _capabilities_by_name(request)
|
||||
capability = capabilities.get(model_name, {})
|
||||
return _to_admin_item(
|
||||
updated,
|
||||
supports_reasoning_effort=bool(capability.get("supports_reasoning_effort", False)),
|
||||
default_reasoning_effort=str(capability.get("default_reasoning_effort") or "xhigh"),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{model_name:path}", response_model=ModelAdminItem)
|
||||
async def update_model_configuration(model_name: str, body: ModelUpsertRequest, request: Request) -> ModelAdminItem:
|
||||
admin = await _require_admin(request)
|
||||
|
||||
@@ -742,6 +742,7 @@ def _run_payload(
|
||||
cancel_on_disconnect: bool = False,
|
||||
extra_context: dict[str, Any] | None = None,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
roundtable_file_stream: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||
|
||||
@@ -819,9 +820,20 @@ def _run_payload(
|
||||
# 供 read_peer_delivery 工具按需读取)。只进 context dict,不进 prompt → 不调工具就不耗 token。
|
||||
if extra_context:
|
||||
payload["context"].update(extra_context)
|
||||
if roundtable_file_stream:
|
||||
payload["context"].update(_roundtable_file_stream_context())
|
||||
return payload
|
||||
|
||||
|
||||
def _roundtable_file_stream_context() -> dict[str, bool]:
|
||||
"""会商专用文件流式兼容。关闭时不写 context,本轮仍只看全局开关。"""
|
||||
from deerflow.config.system_settings import get_roundtable_room_limit_settings
|
||||
|
||||
if get_roundtable_room_limit_settings().write_file_compatibility_enabled:
|
||||
return {"roundtable_write_file_compatibility": True}
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/multi-agent/init — Step 2 席位 + 总控初始化(SSE)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1697,6 +1709,7 @@ async def _leader_run(
|
||||
cancel_on_disconnect=True,
|
||||
# 用户随干预上传的参考文件 → 总控可用 read_file 等工具读取后再派活。
|
||||
files=files,
|
||||
roundtable_file_stream=True,
|
||||
)
|
||||
|
||||
body = _mk_body(current_model)
|
||||
@@ -1980,6 +1993,7 @@ async def _special_run(
|
||||
),
|
||||
# 用户随干预上传的参考文件 → 席位可用 read_file 等工具读取。
|
||||
files=files,
|
||||
roundtable_file_stream=True,
|
||||
)
|
||||
|
||||
t_payload_built = time.perf_counter()
|
||||
|
||||
@@ -456,10 +456,15 @@ class ConversationContextResponse(BaseModel):
|
||||
default=3,
|
||||
description="Fixed number of real user turns retained by the sliding window",
|
||||
)
|
||||
use_question_as_title: bool = Field(
|
||||
default=False,
|
||||
description="Whether the first user question is the final title, without post-answer title generation",
|
||||
)
|
||||
|
||||
|
||||
class ConversationContextUpdateRequest(BaseModel):
|
||||
recent_turn_window_enabled: bool
|
||||
recent_turn_window_enabled: bool | None = None
|
||||
use_question_as_title: bool | None = None
|
||||
|
||||
|
||||
class RoundtableRoomLimitResponse(BaseModel):
|
||||
@@ -467,6 +472,7 @@ class RoundtableRoomLimitResponse(BaseModel):
|
||||
max_rooms: int = 3
|
||||
max_hold_minutes: int = 30
|
||||
max_cycles: int = 8
|
||||
write_file_compatibility_enabled: bool = False
|
||||
|
||||
|
||||
class RoundtableRoomLimitUpdateRequest(BaseModel):
|
||||
@@ -474,6 +480,7 @@ class RoundtableRoomLimitUpdateRequest(BaseModel):
|
||||
max_rooms: int | None = Field(default=None, ge=1, le=20)
|
||||
max_hold_minutes: int | None = Field(default=None, ge=1, le=1440)
|
||||
max_cycles: int | None = Field(default=None, ge=2, le=100)
|
||||
write_file_compatibility_enabled: bool | None = None
|
||||
|
||||
|
||||
class OrdinaryKnowledgeRetrievalResponse(BaseModel):
|
||||
@@ -556,6 +563,7 @@ def _conversation_context_response(settings: ConversationContextSettings) -> Con
|
||||
return ConversationContextResponse(
|
||||
recent_turn_window_enabled=settings.recent_turn_window_enabled,
|
||||
recent_turns=3,
|
||||
use_question_as_title=settings.use_question_as_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -573,13 +581,15 @@ async def update_conversation_context_setting(
|
||||
"""Enable or disable the fixed recent-three-turn window. Admin only."""
|
||||
await _require_admin(request)
|
||||
current = load_system_settings()
|
||||
updated = current.conversation_context.model_copy(
|
||||
update={"recent_turn_window_enabled": body.recent_turn_window_enabled}
|
||||
)
|
||||
updated_fields = body.model_dump(exclude_unset=True, exclude_none=True)
|
||||
if not updated_fields:
|
||||
raise HTTPException(status_code=422, detail="至少需要提供一个上下文设置项")
|
||||
updated = current.conversation_context.model_copy(update=updated_fields)
|
||||
saved = save_system_settings(current.model_copy(update={"conversation_context": updated}))
|
||||
logger.info(
|
||||
"Conversation recent-turn window changed: enabled=%s retained_turns=3",
|
||||
"Conversation runtime settings changed: recent_turn_window=%s use_question_as_title=%s",
|
||||
saved.conversation_context.recent_turn_window_enabled,
|
||||
saved.conversation_context.use_question_as_title,
|
||||
)
|
||||
return _conversation_context_response(saved.conversation_context)
|
||||
|
||||
@@ -590,6 +600,7 @@ def _roundtable_room_limit_response(settings: RoundtableRoomLimitSettings) -> Ro
|
||||
max_rooms=settings.max_rooms,
|
||||
max_hold_minutes=settings.max_hold_minutes,
|
||||
max_cycles=settings.max_cycles,
|
||||
write_file_compatibility_enabled=settings.write_file_compatibility_enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -616,14 +627,17 @@ async def update_roundtable_room_limit_setting(
|
||||
updates["max_hold_minutes"] = body.max_hold_minutes
|
||||
if body.max_cycles is not None:
|
||||
updates["max_cycles"] = body.max_cycles
|
||||
if body.write_file_compatibility_enabled is not None:
|
||||
updates["write_file_compatibility_enabled"] = body.write_file_compatibility_enabled
|
||||
updated = current.roundtable_room_limit.model_copy(update=updates)
|
||||
saved = save_system_settings(current.model_copy(update={"roundtable_room_limit": updated}))
|
||||
logger.info(
|
||||
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s",
|
||||
"Roundtable runtime limits changed: enabled=%s max_rooms=%s max_hold_minutes=%s max_cycles=%s write_file_compatibility=%s",
|
||||
saved.roundtable_room_limit.enabled,
|
||||
saved.roundtable_room_limit.max_rooms,
|
||||
saved.roundtable_room_limit.max_hold_minutes,
|
||||
saved.roundtable_room_limit.max_cycles,
|
||||
saved.roundtable_room_limit.write_file_compatibility_enabled,
|
||||
)
|
||||
return _roundtable_room_limit_response(saved.roundtable_room_limit)
|
||||
|
||||
|
||||
@@ -577,6 +577,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"roundtable_peer_deliveries",
|
||||
# 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。
|
||||
"position_artifact_cap",
|
||||
# 会商专用文件流式兼容。只由会商 run 写入;普通对话不带此键,仍只看全局开关。
|
||||
"roundtable_write_file_compatibility",
|
||||
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
|
||||
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
|
||||
"summarization_enabled",
|
||||
|
||||
@@ -87,6 +87,7 @@ models:
|
||||
temperature: 1.0
|
||||
supports_vision: true
|
||||
supports_thinking: true
|
||||
write_file_transport: buffered_gateway
|
||||
- name: MiniMax-M2.7-fash
|
||||
display_name: MiniMax-M2.7-fash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@@ -107,9 +108,6 @@ models:
|
||||
base_url: https://ai.hzfzlz.com/v1
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
# 不带 max_tokens 时该中转站按自身小默认值截断输出(实测 ~64 token),
|
||||
# 检索词 JSON 会被截断导致解析失败走兜底。
|
||||
# write_file 写整页 HTML 时常超过 4096,截断后只剩 path,沙箱无法流式预览。
|
||||
max_tokens: 32768
|
||||
stream_usage: true
|
||||
supports_thinking: false
|
||||
@@ -125,6 +123,7 @@ models:
|
||||
stream_usage: true
|
||||
supports_thinking: false
|
||||
supports_vision: false
|
||||
write_file_transport: native
|
||||
- name: zai-org/GLM-5-FP8
|
||||
display_name: zai-org/GLM-5-FP8
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@@ -195,6 +194,9 @@ models:
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
# 不带 max_tokens 时该中转站按自身小默认值截断输出(实测 ~64 token),
|
||||
# 检索词 JSON 会被截断导致解析失败走兜底。
|
||||
# write_file 写整页 HTML 时常超过 4096,截断后只剩 path,沙箱无法流式预览。
|
||||
# - name: deepseek-v4-flash
|
||||
# display_name: DeepSeek V4 Flash
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
|
||||
+8
-4
@@ -826,6 +826,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
# create_chat_model(force_disable_thinking=...) so enable_thinking=false is
|
||||
# actually injected. Default off → no behavior change for normal chats.
|
||||
thinking_force_disabled = bool(cfg.get("thinking_force_disabled", False))
|
||||
# Roundtable runs opt in explicitly. Missing/false keeps the global switch.
|
||||
write_file_compatibility = True if cfg.get("roundtable_write_file_compatibility") else None
|
||||
reasoning_effort = cfg.get("reasoning_effort", None)
|
||||
requested_model_name: str | None = cfg.get("model_name") or cfg.get("model")
|
||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||
@@ -1025,7 +1027,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
|
||||
setup_tools = [setup_writing] if writing_setup_quick else [setup_writing, ask_clarification_tool]
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||||
tools=setup_tools,
|
||||
middleware=_build_setup_middlewares(
|
||||
config,
|
||||
@@ -1062,7 +1064,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
)
|
||||
)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||||
tools=setup_tools,
|
||||
middleware=_build_setup_middlewares(
|
||||
config,
|
||||
@@ -1087,7 +1089,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||||
tools=get_available_tools(
|
||||
include_mcp=not classic_knowledge_space_strict,
|
||||
model_name=model_name,
|
||||
@@ -1097,6 +1099,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
allowed_tools=requested_allowed_tools,
|
||||
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
|
||||
app_config=resolved_app_config,
|
||||
write_file_compatibility=write_file_compatibility,
|
||||
)
|
||||
+ [setup_agent],
|
||||
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
|
||||
@@ -1130,6 +1133,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
allowed_tools=requested_allowed_tools,
|
||||
allow_agent_orchestration=agent_id in _COORDINATOR_AGENT_IDS,
|
||||
app_config=resolved_app_config,
|
||||
write_file_compatibility=write_file_compatibility,
|
||||
)
|
||||
)
|
||||
if agent_id == FORCED_RESEARCH_AGENT_ID:
|
||||
@@ -1243,7 +1247,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
)
|
||||
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, app_config=resolved_app_config),
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, force_disable_thinking=thinking_force_disabled, write_file_compatibility=write_file_compatibility, app_config=resolved_app_config),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
system_prompt=system_prompt,
|
||||
|
||||
+13
-6
@@ -12,6 +12,7 @@ from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deerflow.config.system_settings import get_conversation_context_settings
|
||||
from deerflow.config.title_config import get_title_config
|
||||
from deerflow.models import create_chat_model
|
||||
from deerflow.utils.time import now_iso
|
||||
@@ -209,14 +210,15 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
return self._fallback_title(user_msg)
|
||||
|
||||
def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool:
|
||||
"""Check if we should set an instant, question-derived placeholder title.
|
||||
"""Check if we should set an instant, question-derived title.
|
||||
|
||||
Runs in ``before_model`` so the thread shows a sensible title the moment
|
||||
the user sends their first message — eliminating the "untitled" window
|
||||
while the (possibly long) first answer streams. No LLM call, no latency.
|
||||
the user sends their first message — eliminating the "untitled" window.
|
||||
When the administrator selects question-derived titles, this also works
|
||||
when LLM title generation is disabled because no title model is needed.
|
||||
"""
|
||||
config = self._get_title_config()
|
||||
if not config.enabled:
|
||||
if not config.enabled and not get_conversation_context_settings().use_question_as_title:
|
||||
return False
|
||||
|
||||
# Already has a title (provisional or final) — nothing to do.
|
||||
@@ -333,8 +335,13 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||
if not user_msg.strip():
|
||||
return None
|
||||
title = self._validated_fallback_title(state, user_msg)
|
||||
logger.debug("TitleMiddleware: set provisional title %r before first model call", title)
|
||||
return {"title": title, "title_provisional": True}
|
||||
use_question_as_title = get_conversation_context_settings().use_question_as_title
|
||||
logger.debug(
|
||||
"TitleMiddleware: set %s title %r before first model call",
|
||||
"final question-derived" if use_question_as_title else "provisional question-derived",
|
||||
title,
|
||||
)
|
||||
return {"title": title, "title_provisional": not use_question_as_title}
|
||||
|
||||
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
||||
"""Generate a local fallback title without blocking on an LLM call."""
|
||||
|
||||
+12
-25
@@ -576,6 +576,13 @@ class ConversationContextSettings(BaseModel):
|
||||
default=False,
|
||||
description="Whether model requests use the fixed recent-three-turn sliding window.",
|
||||
)
|
||||
use_question_as_title: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Whether the first user question is used as the final conversation title, "
|
||||
"skipping post-answer LLM title generation."
|
||||
),
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@@ -629,22 +636,12 @@ class RoundtableRoomLimitSettings(BaseModel):
|
||||
"The value is captured when an orchestration starts."
|
||||
),
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ConversationContextSettings(BaseModel):
|
||||
"""Deployment-wide short-context policy for chat model requests.
|
||||
|
||||
The recent-turn window is deliberately opt-in. When enabled, the lead
|
||||
agent keeps the persisted conversation intact but sends only the latest
|
||||
three real user turns (plus their assistant/tool continuations) to the
|
||||
model. This avoids an extra summarization-model call while preserving the
|
||||
complete transcript for the UI and checkpoint recovery.
|
||||
"""
|
||||
|
||||
recent_turn_window_enabled: bool = Field(
|
||||
write_file_compatibility_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether model requests use the fixed recent-three-turn sliding window.",
|
||||
description=(
|
||||
"When true, roundtable leader/seat/step-3 runs use buffered-gateway "
|
||||
"write_file streaming. Ordinary chat keeps the global switch."
|
||||
),
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
@@ -714,7 +711,6 @@ class SystemSettings(BaseModel):
|
||||
write_file_compatibility: WriteFileCompatibilitySettings = Field(
|
||||
default_factory=WriteFileCompatibilitySettings
|
||||
)
|
||||
conversation_context: ConversationContextSettings = Field(default_factory=ConversationContextSettings)
|
||||
roundtable_room_limit: RoundtableRoomLimitSettings = Field(default_factory=RoundtableRoomLimitSettings)
|
||||
local_wiki_encoder: LocalWikiEncoderSettings = Field(default_factory=LocalWikiEncoderSettings)
|
||||
strategy_projects: StrategyProjectSettings = Field(default_factory=StrategyProjectSettings)
|
||||
@@ -851,15 +847,6 @@ def get_roundtable_room_limit_settings() -> RoundtableRoomLimitSettings:
|
||||
return RoundtableRoomLimitSettings()
|
||||
|
||||
|
||||
def get_conversation_context_settings() -> ConversationContextSettings:
|
||||
"""Return the admin-controlled recent-turn policy, failing closed."""
|
||||
try:
|
||||
return load_system_settings().conversation_context
|
||||
except Exception:
|
||||
logger.debug("Failed to read conversation context setting; keeping the recent-turn window disabled", exc_info=True)
|
||||
return ConversationContextSettings()
|
||||
|
||||
|
||||
def save_system_settings(settings: SystemSettings) -> SystemSettings:
|
||||
"""Persist the settings document atomically, returning the saved value."""
|
||||
global _settings_cache_key, _settings_cache_value
|
||||
|
||||
@@ -136,7 +136,7 @@ def _enable_stream_usage_by_default(model_use_path: str, model_settings_from_con
|
||||
model_settings_from_config["stream_usage"] = True
|
||||
|
||||
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, **kwargs) -> BaseChatModel:
|
||||
def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *, app_config: AppConfig | None = None, force_disable_thinking: bool = False, write_file_compatibility: bool | None = None, **kwargs) -> BaseChatModel:
|
||||
"""Create a chat model instance from the config.
|
||||
|
||||
Args:
|
||||
@@ -187,10 +187,17 @@ def create_chat_model(name: str | None = None, thinking_enabled: bool = False, *
|
||||
},
|
||||
)
|
||||
configured_write_file_transport = model_config.write_file_transport
|
||||
runtime_write_file_compatibility = get_write_file_compatibility_settings()
|
||||
# ``None`` follows the global switch, so a roundtable run with its own
|
||||
# switch off still uses compatibility when the global switch is on.
|
||||
# ``True`` is the roundtable opt-in used when the global switch is off.
|
||||
compatibility_enabled = (
|
||||
get_write_file_compatibility_settings().enabled
|
||||
if write_file_compatibility is None
|
||||
else write_file_compatibility
|
||||
)
|
||||
effective_write_file_transport = (
|
||||
WriteFileTransport.BUFFERED_GATEWAY
|
||||
if runtime_write_file_compatibility.enabled
|
||||
if compatibility_enabled
|
||||
else configured_write_file_transport
|
||||
)
|
||||
if model_config.tool_call_transport.value != "native":
|
||||
|
||||
@@ -289,6 +289,8 @@ class SubagentExecutor:
|
||||
thread_data: ThreadDataState | None = None,
|
||||
thread_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
*,
|
||||
write_file_compatibility: bool | None = None,
|
||||
):
|
||||
"""Initialize the executor.
|
||||
|
||||
@@ -317,6 +319,7 @@ class SubagentExecutor:
|
||||
self.sandbox_state = sandbox_state
|
||||
self.thread_data = thread_data
|
||||
self.thread_id = thread_id
|
||||
self.write_file_compatibility = True if write_file_compatibility else None
|
||||
# Generate trace_id if not provided (for top-level calls)
|
||||
self.trace_id = trace_id or str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -334,7 +337,12 @@ class SubagentExecutor:
|
||||
app_config = self.app_config or get_app_config()
|
||||
if self.model_name is None:
|
||||
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
|
||||
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config)
|
||||
model = create_chat_model(
|
||||
name=self.model_name,
|
||||
thinking_enabled=False,
|
||||
app_config=app_config,
|
||||
write_file_compatibility=self.write_file_compatibility,
|
||||
)
|
||||
|
||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
|
||||
|
||||
|
||||
@@ -157,6 +157,8 @@ async def task_tool(
|
||||
effective_model = resolve_subagent_model_name(config, parent_model, app_config=resolved_app_config)
|
||||
|
||||
# Subagents should not have subagent tools enabled (prevent recursive nesting)
|
||||
parent_context = runtime.context if runtime is not None and isinstance(getattr(runtime, "context", None), dict) else {}
|
||||
roundtable_file_stream = bool(parent_context.get("roundtable_write_file_compatibility"))
|
||||
available_tools_kwargs = {
|
||||
"model_name": effective_model,
|
||||
"groups": parent_tool_groups,
|
||||
@@ -164,6 +166,8 @@ async def task_tool(
|
||||
}
|
||||
if resolved_app_config is not None:
|
||||
available_tools_kwargs["app_config"] = resolved_app_config
|
||||
if roundtable_file_stream:
|
||||
available_tools_kwargs["write_file_compatibility"] = True
|
||||
tools = get_available_tools(**available_tools_kwargs)
|
||||
|
||||
# Create executor
|
||||
@@ -178,6 +182,8 @@ async def task_tool(
|
||||
}
|
||||
if resolved_app_config is not None:
|
||||
executor_kwargs["app_config"] = resolved_app_config
|
||||
if roundtable_file_stream:
|
||||
executor_kwargs["write_file_compatibility"] = True
|
||||
executor = SubagentExecutor(**executor_kwargs)
|
||||
|
||||
# Start background execution (always async to prevent blocking)
|
||||
|
||||
@@ -71,6 +71,7 @@ def get_available_tools(
|
||||
allowed_tools: list[str] | None = None,
|
||||
allow_agent_orchestration: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
write_file_compatibility: bool | None = None,
|
||||
) -> list[BaseTool]:
|
||||
"""Get all available tools from config.
|
||||
|
||||
@@ -105,6 +106,8 @@ def get_available_tools(
|
||||
# requests entirely inside PatchedChatOpenAI.
|
||||
global_write_file_compatibility_enabled = (
|
||||
get_write_file_compatibility_settings().enabled
|
||||
if write_file_compatibility is None
|
||||
else write_file_compatibility
|
||||
)
|
||||
write_renderer_enabled = (
|
||||
not global_write_file_compatibility_enabled
|
||||
|
||||
+31
@@ -4543,6 +4543,37 @@ def test_factory_global_switch_overrides_every_compatible_model(
|
||||
assert second.write_file_transport == "buffered_gateway"
|
||||
|
||||
|
||||
def test_roundtable_override_enables_buffered_gateway_while_global_is_off(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
model_factory,
|
||||
"get_write_file_compatibility_settings",
|
||||
lambda: SimpleNamespace(enabled=False),
|
||||
)
|
||||
config = AppConfig.model_construct(
|
||||
models=[
|
||||
ModelConfig(
|
||||
name="native-model",
|
||||
use="langchain_openai:ChatOpenAI",
|
||||
model="native-model",
|
||||
api_key="test-key",
|
||||
base_url=None,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
ordinary = model_factory.create_chat_model("native-model", app_config=config)
|
||||
roundtable = model_factory.create_chat_model(
|
||||
"native-model",
|
||||
app_config=config,
|
||||
write_file_compatibility=True,
|
||||
)
|
||||
|
||||
assert ordinary.write_file_transport == "native"
|
||||
assert roundtable.write_file_transport == "buffered_gateway"
|
||||
|
||||
|
||||
def test_factory_rejects_bridge_for_non_patched_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config = AppConfig.model_construct(
|
||||
models=[
|
||||
|
||||
@@ -215,6 +215,121 @@ def test_reasoning_capability_route_precedes_generic_model_update_and_accepts_sl
|
||||
]
|
||||
|
||||
|
||||
def test_write_file_transport_route_precedes_generic_model_update() -> None:
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "PUT",
|
||||
"path": "/api/admin/models/write-file-transport/zai-org/GLM-5-FP8",
|
||||
}
|
||||
matching_paths = [route.path for route in router.router.routes if route.matches(scope)[0] is Match.FULL]
|
||||
|
||||
assert matching_paths[:2] == [
|
||||
"/api/admin/models/write-file-transport/{model_name:path}",
|
||||
"/api/admin/models/{model_name:path}",
|
||||
]
|
||||
|
||||
|
||||
def _admin_request():
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
|
||||
app=SimpleNamespace(state=SimpleNamespace()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_file_transport_toggle_persists_native_and_buffered_gateway(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"""models:
|
||||
- name: flash
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: flash
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
saved: dict[str, object] = {}
|
||||
|
||||
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
saved["raw_before"] = raw_before
|
||||
saved["models"] = models
|
||||
return models
|
||||
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
monkeypatch.setattr(router, "_save_models", save_models)
|
||||
|
||||
enabled = await router.update_write_file_transport(
|
||||
"flash",
|
||||
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
|
||||
_admin_request(),
|
||||
)
|
||||
|
||||
assert enabled.write_file_transport == "buffered_gateway"
|
||||
assert saved["models"] == [
|
||||
{
|
||||
"name": "flash",
|
||||
"use": "langchain_openai:ChatOpenAI",
|
||||
"model": "flash",
|
||||
"write_file_transport": "buffered_gateway",
|
||||
}
|
||||
]
|
||||
|
||||
disabled = await router.update_write_file_transport(
|
||||
"flash",
|
||||
router.WriteFileTransportToggleRequest(write_file_transport="native"),
|
||||
_admin_request(),
|
||||
)
|
||||
|
||||
assert disabled.write_file_transport == "native"
|
||||
assert saved["models"][0]["write_file_transport"] == "native"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("use", "use_responses_api"),
|
||||
[
|
||||
("langchain_anthropic:ChatAnthropic", None),
|
||||
("langchain_openai:ChatOpenAI", True),
|
||||
],
|
||||
)
|
||||
async def test_write_file_transport_rejects_incompatible_models(
|
||||
use: str,
|
||||
use_responses_api: bool | None,
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.yaml"
|
||||
original = f"""models:
|
||||
- name: incompatible
|
||||
use: {use}
|
||||
model: incompatible
|
||||
"""
|
||||
if use_responses_api:
|
||||
original = original.replace(
|
||||
" model: incompatible\n",
|
||||
" model: incompatible\n use_responses_api: true\n",
|
||||
)
|
||||
config_path.write_text(original, encoding="utf-8")
|
||||
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
|
||||
|
||||
async def fail_save_models(_request, _raw_before, _models):
|
||||
raise AssertionError("incompatible models must not be persisted")
|
||||
|
||||
monkeypatch.setattr(router, "_save_models", fail_save_models)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await router.update_write_file_transport(
|
||||
"incompatible",
|
||||
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
|
||||
_admin_request(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert config_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def _reasoning_request(store, *, config: AppConfig | None = None):
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
|
||||
@@ -745,6 +745,7 @@ async def test_admin_can_change_roundtable_room_limit_without_restart(
|
||||
assert ss.get_roundtable_room_limit_settings().max_rooms == 3
|
||||
assert ss.get_roundtable_room_limit_settings().max_hold_minutes == 30
|
||||
assert ss.get_roundtable_room_limit_settings().max_cycles == 8
|
||||
assert ss.get_roundtable_room_limit_settings().write_file_compatibility_enabled is False
|
||||
|
||||
saved = await ss_router.update_roundtable_room_limit_setting(
|
||||
ss_router.RoundtableRoomLimitUpdateRequest(
|
||||
@@ -762,6 +763,36 @@ async def test_admin_can_change_roundtable_room_limit_without_restart(
|
||||
assert ss.load_system_settings().roundtable_room_limit.max_rooms == 5
|
||||
assert ss.load_system_settings().roundtable_room_limit.max_hold_minutes == 45
|
||||
assert ss.load_system_settings().roundtable_room_limit.max_cycles == 12
|
||||
assert saved.write_file_compatibility_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roundtable_file_stream_is_only_attached_to_roundtable_runs(
|
||||
tmp_runtime_home: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_patch_admin(monkeypatch)
|
||||
base = dict(
|
||||
agent_name="roundtable-seat",
|
||||
model_name="test-model",
|
||||
thread_id="thread-1",
|
||||
new_message="test",
|
||||
excluded_tools=[],
|
||||
skill_stop_names=[],
|
||||
)
|
||||
before = _run_payload(**base, roundtable_file_stream=True)
|
||||
assert "roundtable_write_file_compatibility" not in before["context"]
|
||||
|
||||
saved = await ss_router.update_roundtable_room_limit_setting(
|
||||
ss_router.RoundtableRoomLimitUpdateRequest(write_file_compatibility_enabled=True),
|
||||
SimpleNamespace(),
|
||||
)
|
||||
assert saved.write_file_compatibility_enabled is True
|
||||
|
||||
opted_in = _run_payload(**base, roundtable_file_stream=True)
|
||||
assert opted_in["context"]["roundtable_write_file_compatibility"] is True
|
||||
ordinary = _run_payload(**base)
|
||||
assert "roundtable_write_file_compatibility" not in ordinary["context"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -114,6 +114,46 @@ def _title_config() -> TitleConfig:
|
||||
)
|
||||
|
||||
|
||||
def test_admin_question_title_switch_finalizes_question_before_first_model_call(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
title_middleware_module,
|
||||
"get_conversation_context_settings",
|
||||
lambda: SimpleNamespace(use_question_as_title=True),
|
||||
)
|
||||
middleware = TitleMiddleware(
|
||||
title_config=_title_config().model_copy(update={"enabled": False})
|
||||
)
|
||||
state = {
|
||||
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
"title": "请帮我分析这份合同",
|
||||
"title_provisional": False,
|
||||
}
|
||||
|
||||
|
||||
def test_admin_question_title_switch_off_keeps_async_title_generation(monkeypatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
title_middleware_module,
|
||||
"get_conversation_context_settings",
|
||||
lambda: SimpleNamespace(use_question_as_title=False),
|
||||
)
|
||||
middleware = TitleMiddleware(title_config=_title_config())
|
||||
state = {
|
||||
"messages": [SimpleNamespace(type="human", content="请帮我分析这份合同")],
|
||||
}
|
||||
|
||||
result = middleware.before_model(state, runtime=SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
"title": "请帮我分析这份合同",
|
||||
"title_provisional": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_thread_title_updates_checkpoint_and_display_name(monkeypatch) -> None:
|
||||
async def _fake_generate(self, state):
|
||||
|
||||
@@ -493,4 +493,3 @@ task_deeplink:
|
||||
taskDirection: "正向引导 + 风险预警"
|
||||
taskName: "任务01:暑期文旅舆情综合研判"
|
||||
taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。"
|
||||
taskType: 1
|
||||
Reference in new issue
Block a user