Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj
This commit is contained in:
commit
440b490b0d
22 files changed
+1051
-190
No files matched your search
@@ -165,10 +165,26 @@ export function SkillPickerSheet({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const validation = await validateUpload.mutateAsync(file);
|
||||
const validation = await validateUpload.mutateAsync({
|
||||
file,
|
||||
targetCategory: "custom",
|
||||
});
|
||||
if (validation.conflict) {
|
||||
const owner = validation.owner_username ?? validation.owner_user_id;
|
||||
if (validation.owned_by_other && owner) {
|
||||
toast.error(
|
||||
`技能「${validation.skill_name}」已由用户「${owner}」上传,不能覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (validation.owned_by_me && validation.can_overwrite) {
|
||||
toast.warning(
|
||||
`您已上传过同名技能「${validation.skill_name}」,可到「技能」设置页重复上传并覆盖原版本。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(
|
||||
`已存在同名技能「${validation.skill_name}」,请到「技能」设置页上传以选择是否覆盖。`,
|
||||
`已存在同名技能「${validation.skill_name}」,当前不可覆盖。`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1201,7 +1201,24 @@ function InstallSkillDialog({
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
const setNameZhMutation = useSetSkillNameZh();
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
const generateNameZhMutation = useGenerateSkillNameZh();
|
||||
|
||||
const validateFile = useCallback(
|
||||
async (selected: File, category: "custom" | "public") => {
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync({
|
||||
file: selected,
|
||||
targetCategory: category,
|
||||
});
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[validateMutation],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFile(null);
|
||||
@@ -1240,19 +1257,12 @@ function InstallSkillDialog({
|
||||
if (!lower.endsWith(".zip") && !lower.endsWith(".skill")) {
|
||||
toast.error(dialog.rejectInvalidExt);
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
try {
|
||||
const result = await validateMutation.mutateAsync(selected);
|
||||
setValidation(result);
|
||||
} catch (err) {
|
||||
setValidationError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.rejectInvalidExt, validateMutation],
|
||||
);
|
||||
}
|
||||
setFile(selected);
|
||||
await validateFile(selected, targetCategory);
|
||||
},
|
||||
[dialog.rejectInvalidExt, targetCategory, validateFile],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -1329,12 +1339,29 @@ function InstallSkillDialog({
|
||||
}
|
||||
}, [dialog.nameZhSaved, installedName, nameZhDraft, onClose, reset, setNameZhMutation]);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && validation.can_install_as_is;
|
||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||
const customConflictMessage = !validation
|
||||
? ""
|
||||
: otherCustomConflict
|
||||
? dialog.conflictOwnedByOther
|
||||
.replace("{name}", validation.skill_name)
|
||||
.replace(
|
||||
"{owner}",
|
||||
validation.owner_username ?? validation.owner_user_id ?? "—",
|
||||
)
|
||||
: validation.owned_by_me
|
||||
? (ownCustomCanOverwrite
|
||||
? dialog.conflictOwnedByMe
|
||||
: dialog.conflictOwnedByMeWrongTarget
|
||||
).replace("{name}", validation.skill_name)
|
||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -1453,9 +1480,12 @@ function InstallSkillDialog({
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => {
|
||||
setTargetCategory(cat);
|
||||
if (file) void validateFile(file, cat);
|
||||
}}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
@@ -1513,15 +1543,18 @@ function InstallSkillDialog({
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
{isCustomConflict && (
|
||||
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{desensitize(
|
||||
dialog.conflictCustom.replace("{name}", validation.skill_name),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isCustomConflict && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-start gap-2 text-xs",
|
||||
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{desensitize(customConflictMessage)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
|
||||
@@ -591,20 +591,26 @@ export const enUS: Translations = {
|
||||
chooseFile: "Choose file",
|
||||
validating: "Validating archive…",
|
||||
installing: "Installing…",
|
||||
replace: "Replace existing",
|
||||
replace: "Upload again and replace",
|
||||
install: "Install",
|
||||
cancel: "Cancel",
|
||||
nameLabel: "Name",
|
||||
descriptionLabel: "Description",
|
||||
licenseLabel: "License",
|
||||
toolsLabel: "Allowed tools",
|
||||
warningsLabel: "Warnings",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||
warningsLabel: "Warnings",
|
||||
conflictOwnedByMe:
|
||||
"You already uploaded a skill named '{name}'. You may upload it again and replace your existing copy (its enabled state will be kept).",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"The custom skill '{name}' belongs to you. Switch the target to 'Custom skill' to replace it.",
|
||||
conflictOwnedByOther:
|
||||
"Skill '{name}' was uploaded by user '{owner}' and cannot be replaced. Rename the skill before uploading.",
|
||||
conflictCustom:
|
||||
"A custom skill named '{name}' already exists, but its uploader could not be identified. Rename it to avoid overwriting someone else's skill.",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Click 'Upload again and replace' to overwrite it.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
|
||||
@@ -525,9 +525,12 @@ export interface Translations {
|
||||
nameLabel: string;
|
||||
descriptionLabel: string;
|
||||
licenseLabel: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictCustom: string;
|
||||
toolsLabel: string;
|
||||
warningsLabel: string;
|
||||
conflictOwnedByMe: string;
|
||||
conflictOwnedByMeWrongTarget: string;
|
||||
conflictOwnedByOther: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
|
||||
@@ -566,18 +566,24 @@ export const zhCN: Translations = {
|
||||
chooseFile: "选择文件",
|
||||
validating: "正在校验文件…",
|
||||
installing: "安装中…",
|
||||
replace: "覆盖已有",
|
||||
replace: "重复上传并覆盖",
|
||||
install: "安装",
|
||||
cancel: "取消",
|
||||
nameLabel: "名称",
|
||||
descriptionLabel: "描述",
|
||||
licenseLabel: "许可证",
|
||||
toolsLabel: "允许的工具",
|
||||
warningsLabel: "警告",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||
warningsLabel: "警告",
|
||||
conflictOwnedByMe:
|
||||
"您已上传过同名技能 “{name}”,可重复上传并覆盖原版本(原启用状态保留)。",
|
||||
conflictOwnedByMeWrongTarget:
|
||||
"同名技能 “{name}” 是您上传的自定义技能,请切换到「自定义技能」后覆盖。",
|
||||
conflictOwnedByOther:
|
||||
"技能 “{name}” 已由用户 “{owner}” 上传,不能覆盖,请修改技能名称后再上传。",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”,但无法确认上传用户,为避免误覆盖请修改技能名称。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。点击「重复上传并覆盖」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
|
||||
@@ -175,16 +175,24 @@ export interface PreviewSkillInstallResponse {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidateSkillUploadResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
license: string | null;
|
||||
allowed_tools: string[];
|
||||
conflict: boolean;
|
||||
conflict_kind: "public" | "custom" | null;
|
||||
warnings: string[];
|
||||
}
|
||||
export interface ValidateSkillUploadResponse {
|
||||
ok: boolean;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
license: string | null;
|
||||
allowed_tools: string[];
|
||||
conflict: boolean;
|
||||
conflict_kind: "public" | "custom" | null;
|
||||
can_install_as_is: boolean;
|
||||
can_overwrite: boolean;
|
||||
owned_by_me: boolean;
|
||||
owned_by_other: boolean;
|
||||
builtin_conflict: boolean;
|
||||
owner_username?: string | null;
|
||||
owner_user_id?: string | null;
|
||||
message: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function installSkill(
|
||||
request: InstallSkillRequest,
|
||||
@@ -235,13 +243,20 @@ async function parseDetailedError(response: Response, fallback: string): Promise
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
export async function validateSkillUpload(file: File): Promise<ValidateSkillUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/skills/validate`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
export async function validateSkillUpload(
|
||||
file: File,
|
||||
targetCategory: "public" | "custom" = "custom",
|
||||
): Promise<ValidateSkillUploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const params = new URLSearchParams({ target_category: targetCategory });
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/validate?${params.toString()}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Validation failed");
|
||||
}
|
||||
|
||||
@@ -192,11 +192,17 @@ export function useSetSkillAlwaysOn() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useValidateSkillUpload() {
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => validateSkillUpload(file),
|
||||
});
|
||||
}
|
||||
export function useValidateSkillUpload() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
file,
|
||||
targetCategory,
|
||||
}: {
|
||||
file: File;
|
||||
targetCategory?: "public" | "custom";
|
||||
}) => validateSkillUpload(file, targetCategory),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -320,7 +320,7 @@ export async function stopActiveThreadBeforeClarificationReply(
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
}
|
||||
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
function getStreamErrorMessage(error: unknown): string {
|
||||
if (typeof error === "string" && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
@@ -340,8 +340,21 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return nestedError;
|
||||
}
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
/**
|
||||
* React production error #185 is the minified form of "Maximum update depth
|
||||
* exceeded". It is a client render-loop guard, not a backend/model failure,
|
||||
* so it must never be surfaced as the answer's stream error.
|
||||
*/
|
||||
function isReactMaximumUpdateDepthError(message: string): boolean {
|
||||
return (
|
||||
/maximum update depth exceeded/i.test(message) ||
|
||||
/minified react error\s*#185\b/i.test(message) ||
|
||||
/react\.dev\/errors\/185\b/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
const TERMINAL_RUN_STATUSES = new Set([
|
||||
"success",
|
||||
@@ -674,11 +687,17 @@ export function useThreadStream({
|
||||
runMetadataStorageRef.current = getRunMetadataStorage();
|
||||
}
|
||||
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(isMock),
|
||||
assistantId: assistantId ?? "lead_agent",
|
||||
threadId: onStreamThreadId,
|
||||
reconnectOnMount: runMetadataStorageRef.current
|
||||
const thread = useStream<AgentThreadState>({
|
||||
client: getAPIClient(isMock),
|
||||
assistantId: assistantId ?? "lead_agent",
|
||||
threadId: onStreamThreadId,
|
||||
// The SDK's current implementation falls back to synchronous external-
|
||||
// store notifications when this option is omitted. A network chunk can
|
||||
// contain dozens of token events, causing nested React renders and the
|
||||
// intermittent production-only React #185 error. Coalesce notifications
|
||||
// to one visual update per frame; stream data itself is not dropped.
|
||||
throttle: 16,
|
||||
reconnectOnMount: runMetadataStorageRef.current
|
||||
? () => runMetadataStorageRef.current!
|
||||
: false,
|
||||
fetchStateHistory: { limit: 1 },
|
||||
@@ -843,10 +862,21 @@ export function useThreadStream({
|
||||
toast(e.message);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
markStreamActivity();
|
||||
const message = getStreamErrorMessage(error);
|
||||
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
|
||||
onError(error) {
|
||||
markStreamActivity();
|
||||
const message = getStreamErrorMessage(error);
|
||||
// A synchronous SDK store notification used to let React's update-depth
|
||||
// guard escape into the stream promise. Do not present that internal UI
|
||||
// exception as a model failure. Reconcile/rejoin on the next task so the
|
||||
// backend run can continue and the current answer is preserved.
|
||||
if (isReactMaximumUpdateDepthError(message)) {
|
||||
window.setTimeout(() => {
|
||||
setStreamHoldOpen(true);
|
||||
void confirmOrRejoinRef.current("error");
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
// Replacing a stalled local SSE reader aborts the in-flight fetch. That
|
||||
// is expected and must not look like a user-facing "network" failure.
|
||||
if (isAbortLikeError(error)) {
|
||||
return;
|
||||
@@ -1675,13 +1705,20 @@ export function useThreadStream({
|
||||
thread.isLoading,
|
||||
) ?? thread.values;
|
||||
|
||||
const mergedMessages =
|
||||
const mergedMessages =
|
||||
Array.isArray(mergedValues?.messages)
|
||||
? (mergedValues.messages as Message[])
|
||||
: thread.messages;
|
||||
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
: thread.messages;
|
||||
|
||||
const visibleThreadError =
|
||||
thread.error &&
|
||||
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
|
||||
? undefined
|
||||
: thread.error;
|
||||
|
||||
const mergedThread = {
|
||||
...thread,
|
||||
error: visibleThreadError,
|
||||
values: mergedValues,
|
||||
messages:
|
||||
optimisticMessages.length > 0
|
||||
|
||||
@@ -324,7 +324,7 @@ FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api
|
||||
| **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| **Threads search** (`POST /threads/search`, LangGraph-compat) | Conversation listing. **System threads are excluded by default**: rows whose `metadata.system` is truthy (scheduler runs, roundtable workers) never crowd the user's list; a caller whose `metadata` filter explicitly references `thread_type`/`system` (e.g. studio notebooks) opts out of the exclusion. New `query` field = case-insensitive title (`display_name`) substring search, used by the chats page's server-side pagination. `POST /threads/count` takes the same request body (limit/offset ignored) and returns `{total}` under identical filtering/exclusion semantics ? powers the chats page's ??/??? display without fetching rows (`ThreadMetaStore.count`). `ThreadMetaStore.search` gained `exclude_system`/`query` params; JSON-blob filters batch-scan until the requested `limit`/`offset` window is full (so pagination over a heavily-filtered set never under-fills), with a `thread_id` tie-breaker on the `updated_at DESC` ordering for stable OFFSET paging. Tests: `tests/test_thread_meta_search.py` |
|
||||
| **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types |
|
||||
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`. |
|
||||
| **Word export** (`POST /api/writing/export/docx`) | Shared Markdown → DOCX generator used by conversation, artifact, scheduled-task, Deep Research, and AI-writing download surfaces. `app/gateway/word_export.py` builds OOXML with no Python conversion dependency: A4 portrait, 3.7/3.5/2.8/2.6 cm margins, real multilevel heading numbering (`一、` / `(一)` / `1.`), two-character first-line indent on heading levels 1–3, formal body/table styles, page numbers beginning at 1 with odd pages left-aligned and even pages right-aligned, and embedded `app/gateway/assets/fonts/方正小标宋简体.ttf`. Before numbering it removes complete manual heading prefixes (including `2.1` / `2.1.1`) and drops Markdown horizontal rules from Word output. The default ordinary-Q&A Lead Agent prompt emits the matching Markdown heading forms and avoids unordered bullet lists; writing setup, writing mode, notebook, scheduled runs, and SOUL-driven custom agents keep their independent prompts. The font loader validates its internal family, SHA-256, and OpenType embedding permission (`OS/2.fsType`) before export. Tests: `tests/test_word_export.py`, `tests/test_es_query_routing_prompt.py`. |
|
||||
| **Deep Research** (`/api/deep-research`) | Durable, user-owned research sessions/jobs with replayable SSE events and hidden-thread artifacts. `basic`/`quick` use the compact runner; `detailed` creates bounded subtopics, searches/writes sections concurrently, and assembles a report; `deep` recursively searches follow-up questions under a deterministic breadth/depth query budget. `multi_agent` is a LangGraph 1.x editor/researcher/writer/reviewer graph: its initial plan emits `awaiting_input`, the recorder stores the interrupt and releases the lease, and `POST /jobs/{id}/resume` rebuilds the graph from the persisted approved/revised plan. A bounded `custom_outline` is frozen into the session; headings/list entries deterministically supply the detailed and multi-agent plan, and the shared writer receives it only as a guarded content constraint. Completed reports support owner-scoped follow-up (`GET /sessions/{id}/messages`, `POST /sessions/{id}/chat`): context is only the current report, selected sources and recent messages; source-id citations are persisted, while `allow_new_research=true` is explicitly rejected. Owners may change that evidence set only after the job stops via `PATCH /sessions/{id}/sources/{source_id}`; this applies to future follow-ups rather than rewriting a completed report. Optional report illustrations are gated by `config.yaml -> deep_research.image_generation`: the Harness `OpenAICompatibleImageProvider` requires base64 output, the shared runner step writes only bounded `research-image-01..04.(png|jpg|webp)` artifacts and Markdown uses private `deep-research://` links. `GET /sessions/{id}/artifacts/{name}` checks session ownership before serving an image; an unavailable provider yields a warning but never fails the text report. Runners live in `packages/harness/deerflow/agents/deep_research/runners/`, may never import `app.*`, and use only the `AdapterBundle`. |
|
||||
| **Gateway auth** | Owner-scoped Deep Research routes await the platform's asynchronous `get_current_user` resolver before they read or write persistence, ensuring each session and job operation receives a concrete user ID. Deep Research artifacts are always resolved from a whitelisted filename via `/mnt/user-data/outputs/<name>` before mapping to a host path, preserving the sandbox guard on native Windows and Linux hosts. |
|
||||
| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized before JSON parsing |
|
||||
@@ -735,9 +735,13 @@ MySQL index script synchronized.
|
||||
`lead_agent.prompt._build_es_query_routing_section` injects the rule into the
|
||||
default lead-agent system prompt. Custom-agent-only prompts receive it only
|
||||
when their explicit skill allowlist contains `es_query`; disabling the switch
|
||||
or leaving the prompt blank emits no block. Tests:
|
||||
or leaving the prompt blank emits no block. Immediately before an ordinary Q&A
|
||||
graph is created, `lead_agent.agent._log_es_query_routing_registration` checks
|
||||
the exact configured block against the final system-prompt string and logs
|
||||
`enabled`, allowlist eligibility, `registered`, character count, and a short
|
||||
SHA-256 fingerprint (never the prompt body). Tests:
|
||||
`tests/test_es_query_routing_prompt.py`.
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory
|
||||
- **Installation**: `POST /api/skills/install` extracts .skill ZIP archive to custom/ directory. Direct uploads use the ownership-aware `POST /api/skills/validate?target_category=...` preflight plus `POST /api/skills/install-upload`: a same-owner custom conflict may be uploaded again with `overwrite=true`, while a different owner's conflict is non-overwritable and returns that owner's username (falling back to user id). The install endpoint repeats the ownership check, including for admins targeting `custom`, so callers cannot bypass the preflight. Public-skill overwrite remains admin-only.
|
||||
- **Installable sentiment analysis package**: `skill-packages/sentiment-analysis.skill` is deliberately separate from the frontend virtual-agent chat page. The chat page goes through Gateway `POST /api/sentiment-agent/stream` (runtime-config URL/Basic Auth in the body, TLS verify off, raw AG-UI SSE passthrough). The skill package's self-contained `config/sentiment-agent.json` owns the external AG-UI address for agent-skill callers; Basic Auth is resolved from `SENTIMENT_AGENT_AUTH_USERNAME` / `SENTIMENT_AGENT_AUTH_PASSWORD` at runtime (not committed into the package). `scripts/sentiment_stream.py` uses only the Python standard library and converts upstream SSE to stable NDJSON events (`thinking_delta`, `answer_delta`, `done`), with a Markdown convenience mode for normal agent calls. Installation/calling guide: `skill-packages/sentiment-analysis/docs/调用与安装说明.md`.
|
||||
- **Chinese name (`name_zh`)**: a Chinese display name stored in the **`skills` DB table** (`SkillRow.name_zh`, `String(128)`, migration `20260609_01`) ? never written to the on-disk SKILL.md, so it works for built-in skills too. Threaded through `SkillStore` create/ensure_legacy/update/update_admin exactly like `detail`; surfaced on `SkillResponse.name_zh` from the ownership record in `_skill_to_response`. Authorization mirrors `detail`: custom skill ? owner or admin; built-in/public ? admin only (row materialized on demand via `ensure_legacy`). Endpoints (in `app/gateway/routers/skills.py`): `PUT /api/skills/{name}/name-zh` (set/clear), `POST /api/skills/{name}/name-zh/generate` (LLM generates one), `POST /api/skills/name-zh/generate-missing` (????: **admin-only** bulk-generate for every skill that lacks one, bounded-concurrency `asyncio.gather`). **Optional model selection**: both generate endpoints accept an optional JSON body `{model_name}` ? an explicit name wins, else the curator model setting, else the main model (`_resolve_skill_name_zh_model_name`). **Model-error surfacing**: `_generate_skill_name_zh` raises `SkillNameZhModelError` when the model itself fails (build/timeout/API error) ? distinct from the model returning an unusable name (? `None`). The single endpoint maps that to `502 ?????,?????????:?`; the bulk endpoint fails fast on a model-build error (502) and otherwise carries a representative `model_error` string in `SkillNameZhBulkResponse` so the UI can say "N ?????????". The frontend (`skill-settings-page.tsx` list + `skill-detail-dialog.tsx` editor, plus the agent skill pickers/badges in `NewAgentPage.tsx` + `agent-card.tsx`) shows `name_zh || name`, offers per-skill "AI ??" plus an **admin-only** header "???????" button, and a compact model dropdown (`components/workspace/model-name-select.tsx`, default = configured model) beside each action.
|
||||
|
||||
@@ -1611,3 +1615,16 @@ write, log the exception, complete the rewrite with
|
||||
`versionSnapshotFailed: true`, and do not emit a user-facing failure or restore
|
||||
the original. The sandbox uses that signal only to enable its ordinary manual
|
||||
save fallback.
|
||||
|
||||
### Offline local-sandbox Office runtime
|
||||
|
||||
The DeerFlow-only offline deployment uses `LocalSandboxProvider`, so document
|
||||
dependencies belong in the backend image rather than a separate sandbox image.
|
||||
`deploy/linux-docker-offline/Dockerfile.backend` installs python-docx,
|
||||
LibreOffice Writer/Calc/Impress, Pandoc, Poppler, and CJK fonts in addition to
|
||||
the MarkItDown Office extras. `file_conversion.py` normalizes legacy
|
||||
DOC/XLS/PPT and ODT/ODS/ODP through a per-conversion LibreOffice user profile
|
||||
before MarkItDown extraction, avoiding concurrent profile locks. The image
|
||||
build and `OFFLINE_INSTALL.sh --office-check` both run
|
||||
`scripts/office_runtime_selfcheck.py`; that smoke test creates and re-parses
|
||||
minimal DOCX/XLSX/PPTX files and does not need the application database.
|
||||
@@ -60,7 +60,10 @@ RUN python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/python -m pip install --no-cache-dir --upgrade -i "${UV_INDEX_URL}" pip setuptools wheel \
|
||||
&& sed -E '/--hash=/d; s/[[:space:]]+\\$//; s|^-e[[:space:]]+./packages/harness$|./packages/harness|' requirements-offline.txt > /tmp/requirements-docker.txt \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -i "${UV_INDEX_URL}" -r /tmp/requirements-docker.txt \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -i "${UV_INDEX_URL}" "asyncmy>=0.2.11"
|
||||
&& /opt/venv/bin/pip install --no-cache-dir -i "${UV_INDEX_URL}" \
|
||||
"asyncmy>=0.2.11" \
|
||||
"python-docx==1.2.0" \
|
||||
&& /opt/venv/bin/python -c 'import docx, lxml, mammoth, markitdown, openpyxl, pandas, pptx, xlrd; print("Office Python dependencies OK")'
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -92,9 +95,17 @@ RUN if [ -n "${APT_MIRROR}" ]; then \
|
||||
ca-certificates \
|
||||
curl \
|
||||
docker.io \
|
||||
file \
|
||||
fontconfig \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
libmagic1 \
|
||||
libreoffice-calc \
|
||||
libreoffice-impress \
|
||||
libreoffice-writer \
|
||||
nodejs \
|
||||
npm \
|
||||
pandoc \
|
||||
poppler-utils \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -102,6 +113,11 @@ RUN if [ -n "${APT_MIRROR}" ]; then \
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
COPY --from=builder /build/backend /app/backend
|
||||
|
||||
RUN /opt/venv/bin/python -c 'import docx, lxml, mammoth, markitdown, openpyxl, pandas, pptx, xlrd' \
|
||||
&& command -v soffice >/dev/null \
|
||||
&& command -v pandoc >/dev/null \
|
||||
&& fc-list :lang=zh | grep -q .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
ENTRYPOINT ["tini", "--"]
|
||||
|
||||
@@ -140,6 +140,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET/PUT /api/mcp/config` | Manage MCP server configurations |
|
||||
| `GET/PUT /api/skills` | List and manage skills |
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `POST /api/skills/validate`, `POST /api/skills/install-upload` | Validate and upload `.zip`/`.skill` packages with owner-aware conflicts: owners may explicitly overwrite their own copy; other users' copies report the uploader and cannot be overwritten |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
|
||||
@@ -156,7 +157,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET/POST /api/deep-research/*` | Durable Deep Research sessions and jobs. `quick`/`basic` run the compact report flow; `detailed` plans subtopics and writes independent sections; `deep` recursively investigates bounded follow-up branches. All modes reuse DeerFlow's configured material provider, LLM factory, hidden-thread artifacts, and SSE event log. Deep Research session/job/event/source/message writes serialise their rows in the writer transaction, rather than doing a post-commit ORM refresh or readback; this prevents MySQL read/write-splitting replica lag from failing “start writing”, dispatcher claim, report persistence, or follow-up history. Whole-document artifact/report rewrites likewise create their reversible version snapshots from writer-local ORM data and never refresh after commit, so replica lag cannot restore an otherwise successful rewrite. A report is written to the session and confirmed before its Job becomes `completed`; an unconfirmed write is retried and never exposed as a false completion. Retrying an already-created queued job re-nudges the dispatcher, recovering an interrupted HTTP response without creating a second report. If progress-event persistence, durable replay, or live fan-out is briefly unavailable, the worker/stream logs the degradation and continues or reconnects rather than failing the whole job. Whole-report rewrite accepts an optional `reportOutline` Markdown template; the durable job persists it and applies it as the chapter structure constraint. Optional report illustrations use a separately configured OpenAI-compatible image endpoint and are served only through the owning session's authenticated artifact route. |
|
||||
| `/api/enterprise-research/*` | Temporarily disabled. Its router and persistence initialization are not registered, so these endpoints return 404 and no enterprise-research tables are created or migrated during startup. |
|
||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||
| `POST /api/writing/export/docx` | Generate the shared formal Word download from Markdown. Uses the standard-library OOXML builder in `app/gateway/word_export.py`, applies the prescribed A4/margin/heading/body/table/footer profile, and embeds the server-bundled `方正小标宋简体.ttf`; the client computer does not need that font installed. |
|
||||
| `POST /api/writing/export/docx` | Generate the shared formal Word download from Markdown. Uses the standard-library OOXML builder in `app/gateway/word_export.py`, removes manual/compound heading prefixes and Markdown horizontal rules, applies the prescribed A4/margin/heading/body/table/footer profile, and embeds the server-bundled `方正小标宋简体.ttf`; the client computer does not need that font installed. |
|
||||
| `POST /cop/saveSuperiorTask` | TaskCOP compatibility API (DeerFlow Bearer/session authentication required): create a situation-overview task with a sequential four-digit id (`0001`, `0002`, ...) and return the legacy `{state, msg, data}` envelope |
|
||||
| `POST /api/taskcop/import/tasks` | TaskCOP compatibility API (authenticated): import tasks from a JSON array or `tasks`/`records` envelope; every row's supplied `id`/`taskId` is authoritative and a duplicate id completely replaces the stored task |
|
||||
| `DELETE /cop/tasks/{task_id}` | TaskCOP compatibility API (authenticated): delete one task and its task-scoped situation-report document |
|
||||
@@ -511,7 +512,10 @@ Key sections:
|
||||
- `sandbox` - Execution environment provider
|
||||
- `skills` - Skills directory paths plus optional prompt routing; `skills.es_query_routing`
|
||||
can inject an editable system-prompt rule that sends flexible Elasticsearch Query DSL
|
||||
requests to the `es_query` skill, and can be disabled without changing code
|
||||
requests to the `es_query` skill, and can be disabled without changing code. Each ordinary
|
||||
Q&A agent build logs `ES query routing prompt registration` with `registered`, eligibility,
|
||||
character count, and a short SHA-256 fingerprint so operators can verify the exact configured
|
||||
block reached the final model system prompt without logging its contents
|
||||
- `title` - Auto-title generation settings
|
||||
- `summarization` - Context summarization settings
|
||||
- `subagents` - Subagent system (enabled/disabled)
|
||||
@@ -943,3 +947,13 @@ The final rewrite commit uses a deliberately short sibling temporary filename,
|
||||
which keeps long Windows artifact paths below the path-length limit. It also
|
||||
recreates a missing `outputs` parent before retrying, so transient
|
||||
sandbox-directory recreation cannot discard a completed rewrite.
|
||||
|
||||
## Offline local-sandbox Office runtime
|
||||
|
||||
The pure DeerFlow offline image treats the backend container as the default
|
||||
`LocalSandboxProvider` runtime. It bundles MarkItDown, python-docx, openpyxl,
|
||||
python-pptx, LibreOffice Headless, Pandoc, Poppler, and CJK fonts. Modern OOXML
|
||||
uploads are parsed directly; legacy DOC/XLS/PPT and ODT/ODS/ODP files are
|
||||
normalized through an isolated LibreOffice profile before Markdown extraction.
|
||||
Run `./OFFLINE_INSTALL.sh --office-check` from the offline bundle to generate
|
||||
and re-parse Word, Excel, and PowerPoint samples without requiring a database.
|
||||
@@ -272,6 +272,14 @@ class SkillUploadValidateResponse(BaseModel):
|
||||
allowed_tools: list[str] = Field(default_factory=list, description="Tools the skill may invoke")
|
||||
conflict: bool = Field(default=False, description="True if a skill with the same name already exists")
|
||||
conflict_kind: str | None = Field(default=None, description="'public' or 'custom' when conflict=true")
|
||||
can_install_as_is: bool = Field(default=True, description="Whether the archive can be installed without overwrite")
|
||||
can_overwrite: bool = Field(default=False, description="Whether the caller may overwrite the conflicting skill")
|
||||
owned_by_me: bool = Field(default=False, description="True when the caller owns the conflicting custom skill")
|
||||
owned_by_other: bool = Field(default=False, description="True when another user owns the conflicting custom skill")
|
||||
builtin_conflict: bool = Field(default=False, description="True when the name conflicts with a built-in/public skill")
|
||||
owner_username: str | None = Field(default=None, description="Friendly username for a conflicting skill owned by another user")
|
||||
owner_user_id: str | None = Field(default=None, description="Owner user id for a conflicting custom skill")
|
||||
message: str = Field(default="", description="Human-readable upload guidance")
|
||||
warnings: list[str] = Field(default_factory=list, description="Non-fatal warnings to surface to the user")
|
||||
|
||||
|
||||
@@ -972,16 +980,55 @@ async def _persist_uploaded_skill(file: UploadFile) -> Path:
|
||||
"/skills/validate",
|
||||
response_model=SkillUploadValidateResponse,
|
||||
summary="Validate Skill Archive (No Install)",
|
||||
description="Inspect an uploaded .zip/.skill archive and report parsed metadata + conflicts. Does NOT install or run the LLM security scan.",
|
||||
description="Inspect an uploaded .zip/.skill archive and report parsed metadata plus ownership-aware conflicts. Does NOT install or run the LLM security scan.",
|
||||
)
|
||||
async def validate_skill_upload(
|
||||
request: Request,
|
||||
file: UploadFile = File(..., description="The .zip or .skill archive to validate"),
|
||||
target_category: SkillCategory = SkillCategory.CUSTOM,
|
||||
config: AppConfig = Depends(get_config),
|
||||
skill_store: SkillStore = Depends(get_skill_store),
|
||||
) -> SkillUploadValidateResponse:
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
result = get_or_new_skill_storage(app_config=config).validate_skill_archive(tmp_path)
|
||||
return SkillUploadValidateResponse(**result)
|
||||
(
|
||||
can_install_as_is,
|
||||
can_overwrite,
|
||||
owned_by_me,
|
||||
owned_by_other,
|
||||
builtin_conflict,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
message,
|
||||
) = await _preview_install_target(
|
||||
request=request,
|
||||
skill_store=skill_store,
|
||||
skill_name=str(result.get("skill_name") or ""),
|
||||
target_category=target_category,
|
||||
conflict_kind=result.get("conflict_kind"),
|
||||
)
|
||||
effective_conflict_kind = result.get("conflict_kind")
|
||||
if effective_conflict_kind is None:
|
||||
if owned_by_me or owned_by_other:
|
||||
effective_conflict_kind = "custom"
|
||||
elif builtin_conflict:
|
||||
effective_conflict_kind = "public"
|
||||
return SkillUploadValidateResponse(
|
||||
**{
|
||||
**result,
|
||||
"conflict": bool(result.get("conflict") or effective_conflict_kind),
|
||||
"conflict_kind": effective_conflict_kind,
|
||||
"can_install_as_is": can_install_as_is,
|
||||
"can_overwrite": can_overwrite,
|
||||
"owned_by_me": owned_by_me,
|
||||
"owned_by_other": owned_by_other,
|
||||
"builtin_conflict": builtin_conflict,
|
||||
"owner_username": owner_username,
|
||||
"owner_user_id": owner_user_id,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
@@ -999,7 +1046,7 @@ async def validate_skill_upload(
|
||||
"/skills/install-upload",
|
||||
response_model=SkillInstallResponse,
|
||||
summary="Install Skill from Upload",
|
||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace an existing skill with the same name.",
|
||||
description="Install a skill directly from an uploaded .zip/.skill archive. Set ?overwrite=true to replace the caller's own custom skill, or an existing public skill when installing as an admin.",
|
||||
)
|
||||
async def install_skill_upload(
|
||||
request: Request,
|
||||
@@ -1017,22 +1064,30 @@ async def install_skill_upload(
|
||||
tmp_path = await _persist_uploaded_skill(file)
|
||||
try:
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
# Pre-parse the archive to learn the skill name without writing anything,
|
||||
# so we can reject the upload before touching the filesystem if another
|
||||
# user already owns this name. Admins bypass the ownership check since
|
||||
# they install to the shared public directory.
|
||||
try:
|
||||
validation = storage.validate_skill_archive(tmp_path)
|
||||
preview_name = validation.get("skill_name") if isinstance(validation, dict) else None
|
||||
except Exception:
|
||||
preview_name = None
|
||||
if preview_name and not is_admin:
|
||||
existing = await skill_store.get_any(preview_name)
|
||||
if existing and existing.get("owner_user_id") not in (None, user_id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Skill '{preview_name}' already exists and is owned by another user",
|
||||
)
|
||||
# Re-run the same ownership-aware preflight used by /skills/validate.
|
||||
# This closes the time-of-check/time-of-use gap and prevents a crafted
|
||||
# request (including an admin installing into custom/) from replacing a
|
||||
# different user's skill.
|
||||
validation = storage.validate_skill_archive(tmp_path)
|
||||
preview_name = str(validation.get("skill_name") or "")
|
||||
(
|
||||
can_install_as_is,
|
||||
can_overwrite,
|
||||
_owned_by_me,
|
||||
_owned_by_other,
|
||||
_builtin_conflict,
|
||||
_owner_user_id,
|
||||
_owner_username,
|
||||
conflict_message,
|
||||
) = await _preview_install_target(
|
||||
request=request,
|
||||
skill_store=skill_store,
|
||||
skill_name=preview_name,
|
||||
target_category=target_category,
|
||||
conflict_kind=validation.get("conflict_kind"),
|
||||
)
|
||||
if (overwrite and not can_install_as_is and not can_overwrite) or (not overwrite and not can_install_as_is):
|
||||
raise HTTPException(status_code=409, detail=conflict_message)
|
||||
result = await storage.ainstall_skill_from_archive(tmp_path, overwrite=overwrite, require_skill_suffix=False, target_category=target_category)
|
||||
try:
|
||||
from deerflow.skills.usage import mark_uploaded
|
||||
@@ -1075,7 +1130,8 @@ async def _preview_install_target(
|
||||
skill_store: SkillStore,
|
||||
skill_name: str,
|
||||
target_category: SkillCategory,
|
||||
) -> tuple[bool, bool, bool, bool, str | None, str | None, str]:
|
||||
conflict_kind: str | None = None,
|
||||
) -> tuple[bool, bool, bool, bool, bool, str | None, str | None, str]:
|
||||
"""Return installability / ownership info for a parsed skill name."""
|
||||
|
||||
user_id = _current_user_id(request)
|
||||
@@ -1085,27 +1141,44 @@ async def _preview_install_target(
|
||||
owner_username: str | None = None
|
||||
if owner_user_id:
|
||||
try:
|
||||
attached = await _attach_owner_usernames(
|
||||
[SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)]
|
||||
)
|
||||
attached = await _attach_owner_usernames([SkillResponse(name=skill_name, description="", license=None, category=SkillCategory.CUSTOM, owner_user_id=owner_user_id)])
|
||||
owner_username = attached[0].owner_username if attached else None
|
||||
except Exception:
|
||||
owner_username = None
|
||||
|
||||
builtin_conflict = bool(existing and owner_user_id is None)
|
||||
owned_by_me = bool(existing and owner_user_id == user_id)
|
||||
owned_by_other = bool(existing and owner_user_id not in (None, user_id))
|
||||
custom_conflict = conflict_kind == "custom" or bool(existing and owner_user_id is not None)
|
||||
builtin_conflict = conflict_kind == "public" or bool(existing and owner_user_id is None and conflict_kind != "custom")
|
||||
owned_by_me = bool(custom_conflict and owner_user_id == user_id)
|
||||
owned_by_other = bool(custom_conflict and owner_user_id not in (None, user_id))
|
||||
unattributed_custom_conflict = bool(custom_conflict and owner_user_id is None and not builtin_conflict)
|
||||
|
||||
if target_category == SkillCategory.PUBLIC and not is_admin:
|
||||
return False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory"
|
||||
return False, False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, "Only admin users can install skills to the public directory"
|
||||
|
||||
if target_category == SkillCategory.PUBLIC:
|
||||
if custom_conflict:
|
||||
who = owner_username or owner_user_id
|
||||
if owned_by_other and who:
|
||||
message = f"Skill '{skill_name}' has already been uploaded by user '{who}' and cannot be overwritten."
|
||||
elif owned_by_me:
|
||||
message = f"You already own custom skill '{skill_name}'. Switch the target to custom to overwrite it."
|
||||
else:
|
||||
message = f"Skill '{skill_name}' already exists as a custom skill and its owner could not be determined."
|
||||
return False, False, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message
|
||||
if builtin_conflict:
|
||||
return False, True, False, False, True, owner_user_id, owner_username, f"Built-in/public skill '{skill_name}' already exists and can be overwritten by an admin."
|
||||
return True, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
|
||||
|
||||
if builtin_conflict:
|
||||
return False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name."
|
||||
return False, False, owned_by_me, owned_by_other, True, owner_user_id, owner_username, f"Skill '{skill_name}' already exists as a built-in/public skill. Please install it under a different name."
|
||||
if owned_by_other:
|
||||
who = owner_username or "another user"
|
||||
return False, owned_by_me, True, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' has already been installed by {who}. Please choose a different name."
|
||||
who = owner_username or owner_user_id or "another user"
|
||||
return False, False, False, True, False, owner_user_id, owner_username, f"Skill '{skill_name}' has already been uploaded by user '{who}' and cannot be overwritten."
|
||||
if owned_by_me:
|
||||
return False, True, False, builtin_conflict, owner_user_id, owner_username, f"You have already installed skill '{skill_name}'. You can keep using it or install this copy under a different name."
|
||||
return True, False, False, builtin_conflict, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
|
||||
return False, True, True, False, False, owner_user_id, owner_username, f"You have already uploaded skill '{skill_name}'. You may upload it again and overwrite your existing copy."
|
||||
if unattributed_custom_conflict:
|
||||
return False, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' already exists, but its owner could not be determined. It cannot be overwritten safely."
|
||||
return True, False, False, False, False, owner_user_id, owner_username, f"Skill '{skill_name}' can be installed."
|
||||
|
||||
|
||||
async def _build_renamed_skill_archive(
|
||||
@@ -1193,11 +1266,12 @@ async def preview_skill_install(
|
||||
skill_file_path = await aresolve_thread_virtual_path(body.thread_id, body.path)
|
||||
validation = get_or_new_skill_storage(app_config=config).validate_skill_archive(skill_file_path, require_skill_suffix=False)
|
||||
skill_name = str(validation.get("skill_name") or "")
|
||||
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target(
|
||||
can_install_as_is, _can_overwrite, owned_by_me, owned_by_other, builtin_conflict, owner_user_id, owner_username, message = await _preview_install_target(
|
||||
request=request,
|
||||
skill_store=skill_store,
|
||||
skill_name=skill_name,
|
||||
target_category=body.target_category,
|
||||
conflict_kind=validation.get("conflict_kind"),
|
||||
)
|
||||
return SkillInstallPreviewResponse(
|
||||
ok=True,
|
||||
@@ -1248,11 +1322,12 @@ async def install_skill(
|
||||
storage = get_or_new_skill_storage(app_config=config)
|
||||
validation = storage.validate_skill_archive(skill_file_path, require_skill_suffix=False)
|
||||
preview_name = requested_name or str(validation.get("skill_name") or "")
|
||||
can_install_as_is, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target(
|
||||
can_install_as_is, _can_overwrite, owned_by_me, owned_by_other, builtin_conflict, _owner_user_id, _owner_username, message = await _preview_install_target(
|
||||
request=request,
|
||||
skill_store=skill_store,
|
||||
skill_name=preview_name,
|
||||
target_category=target_category,
|
||||
conflict_kind=(validation.get("conflict_kind") if requested_name is None or requested_name == validation.get("skill_name") else None),
|
||||
)
|
||||
if not can_install_as_is:
|
||||
if requested_name and requested_name != validation.get("skill_name") and not owned_by_other and not builtin_conflict:
|
||||
|
||||
@@ -214,7 +214,13 @@ def _strip_inline_markdown(text: str) -> str:
|
||||
|
||||
def _strip_manual_heading_number(text: str) -> str:
|
||||
return re.sub(
|
||||
r"^\s*(?:[一二三四五六七八九十百千]+、|[((][一二三四五六七八九十百千]+[))]|\d+[.、]|[((]\d+[))]|\d+[))])\s*",
|
||||
r"^\s*(?:"
|
||||
r"[一二三四五六七八九十百千]+[、..]"
|
||||
r"|[((]\s*[一二三四五六七八九十百千]+\s*[))]"
|
||||
r"|[((]\s*\d+(?:[..]\d+)*\s*[))]"
|
||||
r"|\d+(?:[..]\d+)+(?:[..、])?"
|
||||
r"|\d+[..、))]"
|
||||
r")\s*",
|
||||
"",
|
||||
text,
|
||||
).strip()
|
||||
@@ -338,7 +344,6 @@ def _parse_markdown(markdown: str, explicit_title: str | None) -> tuple[str | No
|
||||
|
||||
if re.fullmatch(r"\s*(?:---|\*\*\*|___)\s*", raw):
|
||||
flush_paragraph()
|
||||
blocks.append(ParagraphBlock("horizontal_rule", "────────"))
|
||||
index += 1
|
||||
continue
|
||||
|
||||
@@ -584,8 +589,6 @@ def _blocks_xml(blocks: list[Block], relationships: _DocumentRelationships) -> s
|
||||
output.append(_paragraph_xml("QuoteText", _inline_xml(block.text, relationships)))
|
||||
elif block.kind == "image":
|
||||
output.append(_image_paragraph_xml(block.text, relationships))
|
||||
elif block.kind == "horizontal_rule":
|
||||
output.append(_paragraph_xml("BodyText", _run_xml(block.text), alignment="center"))
|
||||
else:
|
||||
output.append(_paragraph_xml("BodyText", _inline_xml(block.text, relationships)))
|
||||
return "".join(output)
|
||||
@@ -677,6 +680,7 @@ def _styles_xml() -> str:
|
||||
font=HEADING_1_FONT_FAMILY,
|
||||
size=BODY_SIZE_HALF_POINTS,
|
||||
alignment="left",
|
||||
first_line_chars=200,
|
||||
keep_next=True,
|
||||
keep_lines=True,
|
||||
outline_level=0,
|
||||
@@ -687,6 +691,7 @@ def _styles_xml() -> str:
|
||||
font=HEADING_2_FONT_FAMILY,
|
||||
size=BODY_SIZE_HALF_POINTS,
|
||||
alignment="left",
|
||||
first_line_chars=200,
|
||||
keep_next=True,
|
||||
keep_lines=True,
|
||||
outline_level=1,
|
||||
@@ -700,6 +705,7 @@ def _styles_xml() -> str:
|
||||
font=BODY_FONT_FAMILY,
|
||||
size=BODY_SIZE_HALF_POINTS,
|
||||
alignment="left",
|
||||
first_line_chars=200 if level == 3 else 0,
|
||||
keep_next=True,
|
||||
keep_lines=True,
|
||||
outline_level=level - 1,
|
||||
@@ -769,10 +775,11 @@ def _styles_xml() -> str:
|
||||
|
||||
def _heading_level_xml(level: int, format_value: str, text: str, font: str) -> str:
|
||||
restart = f'<w:lvlRestart w:val="{level}"/>' if level > 0 else ""
|
||||
first_line_chars = ' w:firstLineChars="200"' if level <= 2 else ""
|
||||
return (
|
||||
f'<w:lvl w:ilvl="{level}"><w:start w:val="1"/>{restart}<w:numFmt w:val="{format_value}"/>'
|
||||
f'<w:lvlText w:val="{_xml_attr(text)}"/><w:lvlJc w:val="left"/>'
|
||||
'<w:pPr><w:ind w:left="0" w:hanging="0"/></w:pPr>'
|
||||
f'<w:pPr><w:ind w:left="0" w:hanging="0"{first_line_chars}/></w:pPr>'
|
||||
f'<w:rPr>{_font_run_properties(font, BODY_SIZE_HALF_POINTS)}</w:rPr></w:lvl>'
|
||||
)
|
||||
|
||||
@@ -798,7 +805,7 @@ def _numbering_xml() -> str:
|
||||
heading_levels = "".join(
|
||||
[
|
||||
_heading_level_xml(0, "chineseCounting", "%1、", HEADING_1_FONT_FAMILY),
|
||||
_heading_level_xml(1, "chineseCounting", "(%2)", HEADING_2_FONT_FAMILY),
|
||||
_heading_level_xml(1, "chineseCounting", "(%2)", HEADING_2_FONT_FAMILY),
|
||||
_heading_level_xml(2, "decimal", "%3.", BODY_FONT_FAMILY),
|
||||
_heading_level_xml(3, "decimal", "(%4)", BODY_FONT_FAMILY),
|
||||
_heading_level_xml(4, "decimal", "%5)", BODY_FONT_FAMILY),
|
||||
|
||||
@@ -106,13 +106,14 @@ DELETE /api/threads/{thread_id}/uploads/{filename}
|
||||
|
||||
以下格式在显式启用 `uploads.auto_convert_documents: true` 时会自动转换为 Markdown:
|
||||
- PDF (`.pdf`)
|
||||
- PowerPoint (`.ppt`, `.pptx`)
|
||||
- Excel (`.xls`, `.xlsx`)
|
||||
- Word (`.doc`, `.docx`)
|
||||
- PowerPoint (`.ppt`, `.pptx`)
|
||||
- Excel (`.xls`, `.xlsx`)
|
||||
- Word (`.doc`, `.docx`)
|
||||
- OpenDocument (`.odt`, `.ods`, `.odp`)
|
||||
|
||||
转换后的 Markdown 文件会保存在同一目录下,文件名为原文件名 + `.md` 扩展名。
|
||||
|
||||
默认情况下,自动转换是关闭的,以避免在网关主机上对不受信任的 Office/PDF 上传执行解析。只有在受信任部署中明确接受此风险时,才应将 `uploads.auto_convert_documents` 设置为 `true`。
|
||||
通用部署可以关闭自动转换,以避免在网关主机上对不受信任的 Office/PDF 上传执行解析。本仓库当前 `config.yaml` 和纯 DeerFlow 离线部署包默认设置为 `uploads.auto_convert_documents: true`,用于受信任的内网部署。
|
||||
|
||||
## Agent 集成
|
||||
|
||||
@@ -247,8 +248,15 @@ backend/.deer-flow/threads/
|
||||
|
||||
### 依赖
|
||||
|
||||
- `markitdown>=0.0.1a2` - 文档转换
|
||||
- `python-multipart>=0.0.20` - 文件上传处理
|
||||
- `markitdown>=0.0.1a2` - 文档转换
|
||||
- `python-multipart>=0.0.20` - 文件上传处理
|
||||
- 纯 DeerFlow 离线镜像还内置 `python-docx`、`openpyxl`、`python-pptx`、LibreOffice Headless、Pandoc、Poppler 和中文字体。旧版 DOC/XLS/PPT 及 ODT/ODS/ODP 会先经 LibreOffice 规范化,再由 MarkItDown 提取。
|
||||
|
||||
离线部署包可在不连接数据库的情况下运行依赖自检:
|
||||
|
||||
```bash
|
||||
./OFFLINE_INSTALL.sh --office-check
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
|
||||
+57
-10
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
@@ -37,7 +38,45 @@ logger = logging.getLogger(__name__)
|
||||
# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。
|
||||
# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。
|
||||
WRITING_SETUP_MAX_ROUNDS = 8
|
||||
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
|
||||
REPORT_STRUCTURE_SETUP_MAX_ROUNDS = 8
|
||||
|
||||
_ES_QUERY_ROUTING_OPEN_TAG = '<skill_routing skill="es_query">'
|
||||
|
||||
|
||||
def _log_es_query_routing_registration(
|
||||
*,
|
||||
system_prompt: str,
|
||||
app_config: AppConfig,
|
||||
agent_id: str | None,
|
||||
available_skills: set[str] | None,
|
||||
) -> None:
|
||||
"""Log whether the exact configured ES routing block reached this Q&A prompt."""
|
||||
routing = app_config.skills.es_query_routing
|
||||
configured_prompt = (routing.prompt or "").strip()
|
||||
expected_block = (
|
||||
f"{_ES_QUERY_ROUTING_OPEN_TAG}\n{configured_prompt}\n</skill_routing>"
|
||||
if configured_prompt
|
||||
else ""
|
||||
)
|
||||
eligible = available_skills is None or "es_query" in available_skills
|
||||
registered = bool(expected_block) and expected_block in system_prompt
|
||||
fingerprint = (
|
||||
hashlib.sha256(configured_prompt.encode("utf-8")).hexdigest()[:12]
|
||||
if configured_prompt
|
||||
else "-"
|
||||
)
|
||||
|
||||
log = logger.info if registered or not routing.enabled or not eligible else logger.warning
|
||||
log(
|
||||
"ES query routing prompt registration: agent_id=%s enabled=%s eligible=%s "
|
||||
"registered=%s prompt_chars=%d prompt_sha256=%s",
|
||||
agent_id or "default",
|
||||
routing.enabled,
|
||||
eligible,
|
||||
registered,
|
||||
len(configured_prompt),
|
||||
fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||
@@ -687,8 +726,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
writing_mode=writing_mode,
|
||||
writing_artifact_path=writing_artifact_path,
|
||||
)
|
||||
else:
|
||||
system_prompt = apply_prompt_template(
|
||||
else:
|
||||
system_prompt = apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_id=agent_id,
|
||||
@@ -698,12 +737,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
available_skills=prompt_available_skills,
|
||||
app_config=resolved_app_config,
|
||||
memory_injection_enabled=memory_injection_enabled,
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
)
|
||||
|
||||
return create_agent(
|
||||
rag_mode_enabled=rag_mode_enabled,
|
||||
is_notebook_mode=is_notebook_mode,
|
||||
notebook_source_ids=notebook_source_ids,
|
||||
is_scheduled_run=bool(cfg.get("is_scheduled_run", False)),
|
||||
)
|
||||
|
||||
_log_es_query_routing_registration(
|
||||
system_prompt=system_prompt,
|
||||
app_config=resolved_app_config,
|
||||
agent_id=agent_id,
|
||||
available_skills=prompt_available_skills,
|
||||
)
|
||||
|
||||
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),
|
||||
tools=default_tools,
|
||||
middleware=_build_middlewares(config, model_name=model_name, agent_name=agent_id, app_config=resolved_app_config),
|
||||
|
||||
+42
-21
@@ -486,7 +486,19 @@ task(description="Oracle Cloud analysis", prompt="...", subagent_type="general-p
|
||||
</subagent_system>"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION = """
|
||||
<ordinary_qa_markdown_format>
|
||||
当普通问答需要输出带章节结构的较长内容时,必须采用以下 Markdown 规范,以便下载为 Word 后得到正确的自动编号和公文标题格式:
|
||||
- 文档主标题使用 `# 主标题`;主标题只写标题文字,不添加章节编号。
|
||||
- 一级章节使用 `## 一、标题`,二级章节使用 `### (一) 标题`,三级章节使用 `#### 1. 标题`。二级编号必须使用半角括号 `(一)`。
|
||||
- 不要在标题中使用 `2.1`、`2.1.1` 等多级阿拉伯数字编号;章节编号直接按上述三种形式书写。
|
||||
- 默认禁止使用以 `-`、`*`、`+` 或 `•` 开头的无序圆点列表;并列内容优先改写为完整自然段,确需逐项表达时使用有序列表。只有用户明确要求无序列表时才使用。
|
||||
- 简短问答不必为了套用格式而强行添加标题;本规范只约束实际使用的标题和列表。
|
||||
</ordinary_qa_markdown_format>
|
||||
"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT_TEMPLATE = """
|
||||
<role>
|
||||
You are {agent_name}, an internal super agent.
|
||||
</role>
|
||||
@@ -612,13 +624,15 @@ You: "Deploying to staging..." [proceed]
|
||||
{acp_section}
|
||||
</working_directory>
|
||||
|
||||
<response_style>
|
||||
- Clear and Concise: Avoid over-formatting unless requested
|
||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||
</response_style>
|
||||
|
||||
<citations>
|
||||
<response_style>
|
||||
- Clear and Concise: Avoid over-formatting unless requested
|
||||
- Natural Tone: Use paragraphs and prose, not bullet points by default
|
||||
- Action-Oriented: Focus on delivering results, not explaining processes
|
||||
</response_style>
|
||||
|
||||
{ordinary_qa_format_section}
|
||||
|
||||
<citations>
|
||||
**CRITICAL: Always include citations when using web search results**
|
||||
|
||||
- **When to Use**: MANDATORY after web_search, web_fetch, or any external information source
|
||||
@@ -1393,7 +1407,7 @@ def build_report_structure_setup_prompt(
|
||||
"""
|
||||
|
||||
|
||||
def apply_prompt_template(
|
||||
def apply_prompt_template(
|
||||
subagent_enabled: bool = False,
|
||||
max_concurrent_subagents: int = 3,
|
||||
*,
|
||||
@@ -1405,10 +1419,11 @@ def apply_prompt_template(
|
||||
is_bootstrap: bool = False,
|
||||
app_config: AppConfig | None = None,
|
||||
memory_injection_enabled: bool = True,
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
) -> str:
|
||||
rag_mode_enabled: bool = False,
|
||||
is_notebook_mode: bool = False,
|
||||
notebook_source_ids: list[int] | None = None,
|
||||
is_scheduled_run: bool = False,
|
||||
) -> str:
|
||||
storage_agent_id = agent_id if agent_id is not None else agent_name
|
||||
|
||||
# Get memory context
|
||||
@@ -1513,7 +1528,7 @@ Writing mode is enabled for this run.
|
||||
|
||||
# Build the offline-network policy block (empty unless DEER_FLOW_OFFLINE_MODE is set).
|
||||
network_policy_section = _build_network_policy_section()
|
||||
rag_mode_section = (
|
||||
rag_mode_section = (
|
||||
"""
|
||||
<rag_citation_mode>
|
||||
RAG/reference display mode is enabled.
|
||||
@@ -1526,10 +1541,15 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
</rag_citation_mode>
|
||||
"""
|
||||
if rag_mode_enabled
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
else ""
|
||||
)
|
||||
ordinary_qa_format_section = (
|
||||
_ORDINARY_QA_MARKDOWN_FORMAT_SECTION
|
||||
if not is_bootstrap and not writing_mode and not is_notebook_mode and not is_scheduled_run
|
||||
else ""
|
||||
)
|
||||
|
||||
# Format the prompt with dynamic skills and memory
|
||||
prompt = SYSTEM_PROMPT_TEMPLATE.format(
|
||||
agent_name=agent_name or storage_agent_id or "CM 助手",
|
||||
soul=get_agent_soul(storage_agent_id),
|
||||
@@ -1542,9 +1562,10 @@ When a search, knowledge, skill, or MCP tool returns a result list:
|
||||
network_policy_section=network_policy_section,
|
||||
subagent_section=subagent_section,
|
||||
subagent_reminder=subagent_reminder,
|
||||
subagent_thinking=subagent_thinking,
|
||||
acp_section=acp_and_mounts_section,
|
||||
)
|
||||
subagent_thinking=subagent_thinking,
|
||||
acp_section=acp_and_mounts_section,
|
||||
ordinary_qa_format_section=ordinary_qa_format_section,
|
||||
)
|
||||
|
||||
prompt = prompt + rag_mode_section + _current_time_context()
|
||||
|
||||
|
||||
+104
-15
@@ -14,25 +14,46 @@ asyncio.to_thread() to avoid blocking the event loop (fixes #1569).
|
||||
No FastAPI or HTTP dependencies — pure utility functions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from deerflow.config.app_config import get_app_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File extensions that should be converted to markdown
|
||||
CONVERTIBLE_EXTENSIONS = {
|
||||
CONVERTIBLE_EXTENSIONS = {
|
||||
".pdf",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".doc",
|
||||
".docx",
|
||||
}
|
||||
".docx",
|
||||
".odt",
|
||||
".ods",
|
||||
".odp",
|
||||
}
|
||||
|
||||
# MarkItDown reads modern OOXML files directly. LibreOffice is used as a
|
||||
# compatibility bridge for legacy binary Office files and OpenDocument files,
|
||||
# which are common in offline/intranet deployments but are not handled
|
||||
# consistently by the pure-Python readers.
|
||||
_LIBREOFFICE_TARGETS = {
|
||||
".doc": ".docx",
|
||||
".ppt": ".pptx",
|
||||
".xls": ".xlsx",
|
||||
".odt": ".docx",
|
||||
".odp": ".pptx",
|
||||
".ods": ".xlsx",
|
||||
}
|
||||
_LIBREOFFICE_TIMEOUT_SECONDS = 120
|
||||
|
||||
# Files larger than this threshold are converted in a background thread.
|
||||
# Small files complete in < 1s synchronously; spawning a thread adds unnecessary
|
||||
@@ -94,24 +115,92 @@ def _convert_pdf_with_pymupdf4llm(file_path: Path) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _convert_with_markitdown(file_path: Path) -> str:
|
||||
def _convert_with_markitdown(file_path: Path) -> str:
|
||||
"""Convert any supported file to markdown text using MarkItDown."""
|
||||
from markitdown import MarkItDown
|
||||
|
||||
md = MarkItDown()
|
||||
return md.convert(str(file_path)).text_content
|
||||
|
||||
|
||||
def _do_convert(file_path: Path, pdf_converter: str) -> str:
|
||||
return md.convert(str(file_path)).text_content
|
||||
|
||||
|
||||
def _convert_with_libreoffice_then_markitdown(file_path: Path) -> str | None:
|
||||
"""Normalize a legacy Office/OpenDocument file, then extract its text.
|
||||
|
||||
Each conversion gets an isolated LibreOffice user profile. This avoids
|
||||
the profile-lock conflict that otherwise appears when several uploads are
|
||||
converted concurrently in the same sandbox container.
|
||||
"""
|
||||
target_suffix = _LIBREOFFICE_TARGETS.get(file_path.suffix.lower())
|
||||
if target_suffix is None:
|
||||
return None
|
||||
|
||||
executable = shutil.which("soffice") or shutil.which("libreoffice")
|
||||
if executable is None:
|
||||
logger.warning(
|
||||
"LibreOffice is unavailable; trying MarkItDown directly for %s",
|
||||
file_path.name,
|
||||
)
|
||||
return None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="deerflow-office-") as temporary:
|
||||
temp_root = Path(temporary).resolve()
|
||||
output_dir = temp_root / "output"
|
||||
profile_dir = temp_root / "profile"
|
||||
output_dir.mkdir()
|
||||
profile_dir.mkdir()
|
||||
command = [
|
||||
executable,
|
||||
"--headless",
|
||||
"--nologo",
|
||||
"--nodefault",
|
||||
"--nolockcheck",
|
||||
"--nofirststartwizard",
|
||||
f"-env:UserInstallation={profile_dir.as_uri()}",
|
||||
"--convert-to",
|
||||
target_suffix.lstrip("."),
|
||||
"--outdir",
|
||||
str(output_dir),
|
||||
str(file_path.resolve()),
|
||||
]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_LIBREOFFICE_TIMEOUT_SECONDS,
|
||||
env={**os.environ, "HOME": str(temp_root)},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.exception("LibreOffice failed to normalize %s", file_path.name)
|
||||
return None
|
||||
|
||||
converted_path = output_dir / f"{file_path.stem}{target_suffix}"
|
||||
if completed.returncode != 0 or not converted_path.is_file():
|
||||
logger.error(
|
||||
"LibreOffice could not normalize %s (exit=%s, stderr=%s)",
|
||||
file_path.name,
|
||||
completed.returncode,
|
||||
completed.stderr.strip()[-1000:],
|
||||
)
|
||||
return None
|
||||
return _convert_with_markitdown(converted_path)
|
||||
|
||||
|
||||
def _do_convert(file_path: Path, pdf_converter: str) -> str:
|
||||
"""Synchronous conversion — called directly or via asyncio.to_thread.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file.
|
||||
pdf_converter: "auto" | "pymupdf4llm" | "markitdown"
|
||||
"""
|
||||
is_pdf = file_path.suffix.lower() == ".pdf"
|
||||
|
||||
if is_pdf and pdf_converter != "markitdown":
|
||||
is_pdf = file_path.suffix.lower() == ".pdf"
|
||||
|
||||
normalized_office_text = _convert_with_libreoffice_then_markitdown(file_path)
|
||||
if normalized_office_text is not None:
|
||||
return normalized_office_text
|
||||
|
||||
if is_pdf and pdf_converter != "markitdown":
|
||||
# Try pymupdf4llm first (auto or explicit)
|
||||
pymupdf_text = _convert_pdf_with_pymupdf4llm(file_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline runtime smoke test for the LocalSandbox Office toolchain."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def require_command(name: str) -> str:
|
||||
path = shutil.which(name)
|
||||
if path is None:
|
||||
raise RuntimeError(f"missing command: {name}")
|
||||
return path
|
||||
|
||||
|
||||
def assert_contains(text: str, expected: str, label: str) -> None:
|
||||
normalized = text.replace("\\_", "_")
|
||||
if expected not in normalized:
|
||||
raise RuntimeError(f"{label} conversion lost test text: {text[:300]!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from docx import Document
|
||||
from markitdown import MarkItDown
|
||||
from openpyxl import Workbook
|
||||
from pptx import Presentation
|
||||
|
||||
soffice = require_command("soffice")
|
||||
require_command("pandoc")
|
||||
require_command("pdftotext")
|
||||
require_command("file")
|
||||
|
||||
subprocess.run(
|
||||
[soffice, "--headless", "--version"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="deerflow-office-selfcheck-") as temporary:
|
||||
root = Path(temporary)
|
||||
|
||||
word_path = root / "word-test.docx"
|
||||
document = Document()
|
||||
document.add_heading("Word 自检", level=1)
|
||||
document.add_paragraph("DEERFLOW_WORD_OK")
|
||||
document.save(word_path)
|
||||
|
||||
excel_path = root / "excel-test.xlsx"
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.title = "自检"
|
||||
worksheet.append(["状态", "DEERFLOW_EXCEL_OK"])
|
||||
workbook.save(excel_path)
|
||||
|
||||
ppt_path = root / "ppt-test.pptx"
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[5])
|
||||
slide.shapes.title.text = "DEERFLOW_PPT_OK"
|
||||
presentation.save(ppt_path)
|
||||
|
||||
converter = MarkItDown()
|
||||
assert_contains(converter.convert(word_path).text_content, "DEERFLOW_WORD_OK", "Word")
|
||||
assert_contains(converter.convert(excel_path).text_content, "DEERFLOW_EXCEL_OK", "Excel")
|
||||
assert_contains(converter.convert(ppt_path).text_content, "DEERFLOW_PPT_OK", "PowerPoint")
|
||||
|
||||
print("OFFICE_RUNTIME_SELF_CHECK=PASS")
|
||||
print("支持:DOC/DOCX、XLS/XLSX、PPT/PPTX、ODT/ODS/ODP、PDF")
|
||||
print("组件:MarkItDown、python-docx、openpyxl、python-pptx、LibreOffice、Pandoc、中文字体")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.agents.lead_agent import agent as agent_module
|
||||
from deerflow.agents.lead_agent import prompt as prompt_module
|
||||
from deerflow.config.skills_config import EsQueryRoutingConfig, SkillsConfig
|
||||
|
||||
@@ -68,3 +70,67 @@ def test_es_query_routing_is_injected_into_lead_and_allowed_custom_agent_prompts
|
||||
assert "自定义 ES 路由规则" in lead_prompt
|
||||
assert '<skill_routing skill="es_query">' in allowed_custom_prompt
|
||||
assert '<skill_routing skill="es_query">' not in denied_custom_prompt
|
||||
|
||||
|
||||
def test_es_query_routing_registration_log_confirms_exact_final_prompt(caplog) -> None:
|
||||
config = _app_config(enabled=True, prompt="自定义 ES 路由规则")
|
||||
system_prompt = '<skill_routing skill="es_query">\n自定义 ES 路由规则\n</skill_routing>'
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=agent_module.__name__):
|
||||
agent_module._log_es_query_routing_registration(
|
||||
system_prompt=system_prompt,
|
||||
app_config=config,
|
||||
agent_id=None,
|
||||
available_skills=None,
|
||||
)
|
||||
|
||||
assert "ES query routing prompt registration" in caplog.text
|
||||
assert "agent_id=default" in caplog.text
|
||||
assert "enabled=True" in caplog.text
|
||||
assert "eligible=True" in caplog.text
|
||||
assert "registered=True" in caplog.text
|
||||
assert "prompt_chars=11" in caplog.text
|
||||
assert "prompt_sha256=" in caplog.text
|
||||
|
||||
|
||||
def test_es_query_routing_registration_log_explains_custom_agent_exclusion(caplog) -> None:
|
||||
config = _app_config(enabled=True, prompt="自定义 ES 路由规则")
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=agent_module.__name__):
|
||||
agent_module._log_es_query_routing_registration(
|
||||
system_prompt="custom agent prompt",
|
||||
app_config=config,
|
||||
agent_id="agent-without-es",
|
||||
available_skills={"other_skill"},
|
||||
)
|
||||
|
||||
assert "agent_id=agent-without-es" in caplog.text
|
||||
assert "enabled=True" in caplog.text
|
||||
assert "eligible=False" in caplog.text
|
||||
assert "registered=False" in caplog.text
|
||||
|
||||
|
||||
def test_formal_markdown_rules_are_scoped_to_ordinary_qa(monkeypatch) -> None:
|
||||
config = _app_config(enabled=False)
|
||||
monkeypatch.setattr(prompt_module, "_get_memory_context", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_skills_prompt_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_build_custom_mounts_section", lambda *args, **kwargs: "")
|
||||
monkeypatch.setattr(prompt_module, "_current_time_context", lambda: "")
|
||||
|
||||
ordinary_prompt = prompt_module.apply_prompt_template(app_config=config)
|
||||
writing_prompt = prompt_module.apply_prompt_template(app_config=config, writing_mode=True)
|
||||
notebook_prompt = prompt_module.apply_prompt_template(app_config=config, is_notebook_mode=True)
|
||||
scheduled_prompt = prompt_module.apply_prompt_template(app_config=config, is_scheduled_run=True)
|
||||
|
||||
assert '<ordinary_qa_markdown_format>' in ordinary_prompt
|
||||
assert "## 一、标题" in ordinary_prompt
|
||||
assert "### (一) 标题" in ordinary_prompt
|
||||
assert "#### 1. 标题" in ordinary_prompt
|
||||
assert "2.1.1" in ordinary_prompt
|
||||
assert "无序圆点列表" in ordinary_prompt
|
||||
assert '<ordinary_qa_markdown_format>' not in writing_prompt
|
||||
assert '<ordinary_qa_markdown_format>' not in notebook_prompt
|
||||
assert '<ordinary_qa_markdown_format>' not in scheduled_prompt
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from deerflow.utils import file_conversion
|
||||
|
||||
|
||||
def test_legacy_word_is_normalized_before_markitdown(monkeypatch, tmp_path: Path) -> None:
|
||||
source = tmp_path / "legacy.doc"
|
||||
source.write_bytes(b"legacy")
|
||||
|
||||
monkeypatch.setattr(file_conversion.shutil, "which", lambda _name: "/usr/bin/soffice")
|
||||
monkeypatch.setattr(
|
||||
file_conversion,
|
||||
"_convert_with_markitdown",
|
||||
lambda path: f"converted:{path.suffix}",
|
||||
)
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
output_dir = Path(command[command.index("--outdir") + 1])
|
||||
(output_dir / "legacy.docx").write_bytes(b"normalized")
|
||||
return SimpleNamespace(returncode=0, stderr="")
|
||||
|
||||
monkeypatch.setattr(file_conversion.subprocess, "run", fake_run)
|
||||
|
||||
assert file_conversion._do_convert(source, "auto") == "converted:.docx"
|
||||
|
||||
|
||||
def test_modern_office_file_does_not_start_libreoffice(monkeypatch, tmp_path: Path) -> None:
|
||||
source = tmp_path / "modern.pptx"
|
||||
source.write_bytes(b"modern")
|
||||
monkeypatch.setattr(
|
||||
file_conversion,
|
||||
"_convert_with_markitdown",
|
||||
lambda path: f"direct:{path.suffix}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
file_conversion.subprocess,
|
||||
"run",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected LibreOffice call")),
|
||||
)
|
||||
|
||||
assert file_conversion._do_convert(source, "auto") == "direct:.pptx"
|
||||
|
||||
|
||||
def test_legacy_office_falls_back_when_libreoffice_is_missing(monkeypatch, tmp_path: Path) -> None:
|
||||
source = tmp_path / "legacy.xls"
|
||||
source.write_bytes(b"legacy")
|
||||
monkeypatch.setattr(file_conversion.shutil, "which", lambda _name: None)
|
||||
monkeypatch.setattr(
|
||||
file_conversion,
|
||||
"_convert_with_markitdown",
|
||||
lambda path: f"fallback:{path.suffix}",
|
||||
)
|
||||
|
||||
assert file_conversion._do_convert(source, "auto") == "fallback:.xls"
|
||||
@@ -5,11 +5,16 @@ from __future__ import annotations
|
||||
import io
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers import skills as skills_router
|
||||
from deerflow.persistence.skills import MemorySkillStore
|
||||
from deerflow.skills.installer import SkillAlreadyExistsError
|
||||
from deerflow.skills.storage.local_skill_storage import LocalSkillStorage
|
||||
from deerflow.skills.types import SkillCategory
|
||||
|
||||
|
||||
def _make_skill_zip(
|
||||
@@ -239,3 +244,218 @@ async def test_install_refuses_to_overwrite_public_skill(
|
||||
await storage.ainstall_skill_from_archive(
|
||||
archive, overwrite=True, require_skill_suffix=False
|
||||
)
|
||||
|
||||
|
||||
# ---------- ownership-aware direct upload conflicts ----------
|
||||
|
||||
|
||||
def _request(user_id: str, *, is_admin: bool = False) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(
|
||||
user=SimpleNamespace(
|
||||
id=user_id,
|
||||
system_role="admin" if is_admin else "user",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _attach_test_owner_names(responses):
|
||||
for response in responses:
|
||||
if response.owner_user_id == "user-b":
|
||||
response.owner_username = "bob"
|
||||
return responses
|
||||
|
||||
|
||||
class _UploadStorage:
|
||||
def __init__(self) -> None:
|
||||
self.install_calls = 0
|
||||
|
||||
def validate_skill_archive(self, _path: Path) -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"skill_name": "demo-skill",
|
||||
"description": "demo",
|
||||
"license": None,
|
||||
"allowed_tools": [],
|
||||
"conflict": True,
|
||||
"conflict_kind": "custom",
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
async def ainstall_skill_from_archive(self, *_args, **_kwargs) -> dict:
|
||||
self.install_calls += 1
|
||||
return {
|
||||
"success": True,
|
||||
"skill_name": "demo-skill",
|
||||
"message": "installed",
|
||||
"overwritten": True,
|
||||
"category": "custom",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_preflight_allows_owner_to_overwrite(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "demo-skill", "owner_user_id": "user-a"})
|
||||
monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names)
|
||||
|
||||
result = await skills_router._preview_install_target(
|
||||
request=_request("user-a"),
|
||||
skill_store=store,
|
||||
skill_name="demo-skill",
|
||||
target_category=SkillCategory.CUSTOM,
|
||||
conflict_kind="custom",
|
||||
)
|
||||
|
||||
can_install_as_is, can_overwrite, owned_by_me, owned_by_other, *_rest = result
|
||||
assert can_install_as_is is False
|
||||
assert can_overwrite is True
|
||||
assert owned_by_me is True
|
||||
assert owned_by_other is False
|
||||
assert "upload it again and overwrite" in result[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_preflight_names_the_other_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "demo-skill", "owner_user_id": "user-b"})
|
||||
monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names)
|
||||
|
||||
result = await skills_router._preview_install_target(
|
||||
request=_request("user-a"),
|
||||
skill_store=store,
|
||||
skill_name="demo-skill",
|
||||
target_category=SkillCategory.CUSTOM,
|
||||
conflict_kind="custom",
|
||||
)
|
||||
|
||||
can_install_as_is, can_overwrite, owned_by_me, owned_by_other = result[:4]
|
||||
assert can_install_as_is is False
|
||||
assert can_overwrite is False
|
||||
assert owned_by_me is False
|
||||
assert owned_by_other is True
|
||||
assert result[6] == "bob"
|
||||
assert "user 'bob'" in result[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_upload_surfaces_the_other_owner(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "demo-skill", "owner_user_id": "user-b"})
|
||||
storage = _UploadStorage()
|
||||
upload_path = tmp_path / "validate.skill"
|
||||
upload_path.write_bytes(b"placeholder")
|
||||
|
||||
async def _persist(_file) -> Path:
|
||||
return upload_path
|
||||
|
||||
monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist)
|
||||
monkeypatch.setattr(
|
||||
skills_router,
|
||||
"get_or_new_skill_storage",
|
||||
lambda **_kwargs: storage,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_router,
|
||||
"_attach_owner_usernames",
|
||||
_attach_test_owner_names,
|
||||
)
|
||||
|
||||
response = await skills_router.validate_skill_upload(
|
||||
request=_request("user-a"),
|
||||
file=object(),
|
||||
target_category=SkillCategory.CUSTOM,
|
||||
config=object(),
|
||||
skill_store=store,
|
||||
)
|
||||
|
||||
assert response.conflict is True
|
||||
assert response.conflict_kind == "custom"
|
||||
assert response.can_overwrite is False
|
||||
assert response.owned_by_other is True
|
||||
assert response.owner_username == "bob"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_upload_owner_can_overwrite_own_skill(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "demo-skill", "owner_user_id": "user-a"})
|
||||
storage = _UploadStorage()
|
||||
upload_path = tmp_path / "owner-upload.skill"
|
||||
upload_path.write_bytes(b"placeholder")
|
||||
|
||||
async def _persist(_file) -> Path:
|
||||
return upload_path
|
||||
|
||||
async def _refresh() -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist)
|
||||
monkeypatch.setattr(
|
||||
skills_router,
|
||||
"get_or_new_skill_storage",
|
||||
lambda **_kwargs: storage,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
skills_router,
|
||||
"refresh_skills_system_prompt_cache_async",
|
||||
_refresh,
|
||||
)
|
||||
monkeypatch.setattr("deerflow.skills.usage.mark_uploaded", lambda _name: None)
|
||||
|
||||
response = await skills_router.install_skill_upload(
|
||||
request=_request("user-a"),
|
||||
file=object(),
|
||||
overwrite=True,
|
||||
target_category=SkillCategory.CUSTOM,
|
||||
config=object(),
|
||||
skill_store=store,
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.overwritten is True
|
||||
assert storage.install_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_upload_rechecks_owner_before_overwrite(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "demo-skill", "owner_user_id": "user-b"})
|
||||
storage = _UploadStorage()
|
||||
upload_path = tmp_path / "upload.skill"
|
||||
upload_path.write_bytes(b"placeholder")
|
||||
|
||||
async def _persist(_file) -> Path:
|
||||
return upload_path
|
||||
|
||||
monkeypatch.setattr(skills_router, "_persist_uploaded_skill", _persist)
|
||||
monkeypatch.setattr(skills_router, "get_or_new_skill_storage", lambda **_kwargs: storage)
|
||||
monkeypatch.setattr(skills_router, "_attach_owner_usernames", _attach_test_owner_names)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await skills_router.install_skill_upload(
|
||||
request=_request("user-a", is_admin=True),
|
||||
file=object(),
|
||||
overwrite=True,
|
||||
target_category=SkillCategory.CUSTOM,
|
||||
config=object(),
|
||||
skill_store=store,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "user 'bob'" in str(exc_info.value.detail)
|
||||
assert storage.install_calls == 0
|
||||
@@ -41,8 +41,10 @@ def test_formal_docx_contains_required_layout_numbering_and_embedded_font(tmp_pa
|
||||
## 一、一级标题
|
||||
这是正文,用于验证首行缩进、两端对齐和固定行距。
|
||||
|
||||
### (一)二级标题
|
||||
#### 1. 三级标题
|
||||
### 2.1 二级标题
|
||||
#### 2.1.1 三级标题
|
||||
|
||||
---
|
||||
|
||||
表1 测试表格
|
||||
| 项目 | 数值 |
|
||||
@@ -77,8 +79,11 @@ def test_formal_docx_contains_required_layout_numbering_and_embedded_font(tmp_pa
|
||||
assert '<w:pStyle w:val="DocumentTitle"/>' in document
|
||||
assert "方正小标宋简体" in document
|
||||
assert "一、一级标题" not in document
|
||||
assert "(一)二级标题" not in document
|
||||
assert "1. 三级标题" not in document
|
||||
assert "2.1 二级标题" not in document
|
||||
assert "2.1.1 三级标题" not in document
|
||||
assert "────────" not in document
|
||||
assert "二级标题" in document
|
||||
assert "三级标题" in document
|
||||
|
||||
assert 'w:line="579" w:lineRule="exact"' in styles
|
||||
assert '<w:ind w:left="0" w:hanging="0" w:firstLineChars="200"/>' in styles
|
||||
@@ -86,11 +91,13 @@ def test_formal_docx_contains_required_layout_numbering_and_embedded_font(tmp_pa
|
||||
assert 'w:val="44"' in styles
|
||||
assert "仿宋" in styles and 'w:val="32"' in styles
|
||||
assert "宋体" in styles and 'w:val="28"' in styles
|
||||
assert styles.count('w:firstLineChars="200"') >= 6
|
||||
|
||||
assert '<w:numFmt w:val="chineseCounting"/>' in numbering
|
||||
assert '<w:lvlText w:val="%1、"/>' in numbering
|
||||
assert '<w:lvlText w:val="(%2)"/>' in numbering
|
||||
assert '<w:lvlText w:val="(%2)"/>' in numbering
|
||||
assert '<w:lvlText w:val="%3."/>' in numbering
|
||||
assert numbering.count('w:firstLineChars="200"') == 3
|
||||
assert '<w:evenAndOddHeaders/>' in settings
|
||||
assert '<w:updateFields w:val="true"/>' in settings
|
||||
|
||||
@@ -114,3 +121,20 @@ def test_export_rejects_wrong_font(tmp_path: Path) -> None:
|
||||
|
||||
with pytest.raises(word_export.WordExportFontError):
|
||||
word_export.build_markdown_docx("正文", font_path=wrong_font)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("一、一级标题", "一级标题"),
|
||||
("(一) 二级标题", "二级标题"),
|
||||
("(一)二级标题", "二级标题"),
|
||||
("1. 三级标题", "三级标题"),
|
||||
("2.1 二级标题", "二级标题"),
|
||||
("2.1.1 三级标题", "三级标题"),
|
||||
("2.1.1. 三级标题", "三级标题"),
|
||||
("正文标题", "正文标题"),
|
||||
],
|
||||
)
|
||||
def test_manual_heading_number_is_removed_as_one_complete_prefix(source: str, expected: str) -> None:
|
||||
assert word_export._strip_manual_heading_number(source) == expected
|
||||
Reference in new issue
Block a user