feat(思维导图助手):思维导图助手增加智能整理总结
parent
a598099fba
commit
ac2b198cd7
|
|
@ -10,12 +10,15 @@ import json
|
|||
import uvicorn
|
||||
import click
|
||||
import zipfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
import glob
|
||||
|
||||
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header
|
||||
from pydantic import BaseModel
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
|
|
@ -358,6 +361,116 @@ def get_infer_result(file_suffix_identifier: str, pdf_name: str, parse_dir: str)
|
|||
api_router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class MindmapOrganizeRequest(BaseModel):
|
||||
markdown: str
|
||||
mode: str = "smart"
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> str:
|
||||
content = text.strip()
|
||||
if content.startswith("```"):
|
||||
content = re.sub(r"^```(?:json|markdown|md)?\s*", "", content, flags=re.IGNORECASE)
|
||||
content = re.sub(r"\s*```$", "", content)
|
||||
return content.strip()
|
||||
|
||||
|
||||
def _call_mindmap_llm(markdown: str, mode: str = "smart") -> str:
|
||||
base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/")
|
||||
model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B")
|
||||
api_key = os.getenv("MINDMAP_LLM_API_KEY", "")
|
||||
timeout = int(os.getenv("MINDMAP_LLM_TIMEOUT", "180"))
|
||||
|
||||
if not base_url:
|
||||
raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL")
|
||||
|
||||
compact_markdown = markdown.strip()
|
||||
max_chars = int(os.getenv("MINDMAP_LLM_MAX_INPUT_CHARS", "30000"))
|
||||
if len(compact_markdown) > max_chars:
|
||||
compact_markdown = compact_markdown[:max_chars] + "\n\n...(后续内容已截断)"
|
||||
|
||||
style_instruction = "保留原文标题结构,并将段落总结成要点。" if mode == "hybrid" else "重新整理文档结构,提炼主题并合并相近段落。"
|
||||
prompt = f"""你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
|
||||
|
||||
要求:
|
||||
1. {style_instruction}
|
||||
2. 不要逐段照抄原文,要归纳、合并、总结。
|
||||
3. 不要编造原文没有的信息。
|
||||
4. 保留关键数字、公式、专有名词、步骤和结论。
|
||||
5. 最大层级不超过 4 层。
|
||||
6. 每个父节点下最多 8 个子节点。
|
||||
7. 节点标题尽量简短,正文说明使用短句列表。
|
||||
8. 只输出 Markdown,不要输出解释、代码块围栏或额外说明。
|
||||
|
||||
原始 Markdown:
|
||||
{compact_markdown}
|
||||
"""
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "你擅长把长文档整理成结构清晰、层次合理的中文思维导图 Markdown。"},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": float(os.getenv("MINDMAP_LLM_TEMPERATURE", "0.2")),
|
||||
}
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
url = f"{base_url}/chat/completions"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", errors="ignore")
|
||||
raise RuntimeError(f"智能整理模型请求失败: HTTP {exc.code} {detail}") from exc
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"智能整理模型请求失败: {exc}") from exc
|
||||
|
||||
message = result.get("choices", [{}])[0].get("message", {})
|
||||
content = message.get("content", "")
|
||||
organized = _extract_json_object(content)
|
||||
if not organized:
|
||||
raise RuntimeError("智能整理模型未返回有效内容")
|
||||
return organized
|
||||
|
||||
|
||||
async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str):
|
||||
try:
|
||||
_store_task_progress(task_id, {
|
||||
"progress": 10,
|
||||
"stage": "准备智能整理",
|
||||
"status": "processing",
|
||||
"error": None,
|
||||
"file_names": "",
|
||||
"result_md": None,
|
||||
})
|
||||
_update_task_progress(task_id, 35, "调用智能整理模型")
|
||||
organized = await asyncio.to_thread(_call_mindmap_llm, markdown, mode)
|
||||
state = _get_task_progress(task_id) or {}
|
||||
state.update({
|
||||
"progress": 100,
|
||||
"stage": "智能整理完成",
|
||||
"status": "completed",
|
||||
"error": None,
|
||||
"result_md": organized,
|
||||
})
|
||||
_store_task_progress(task_id, state)
|
||||
except Exception as exc:
|
||||
logger.exception(exc)
|
||||
state = _get_task_progress(task_id) or {}
|
||||
state.update({
|
||||
"progress": 100,
|
||||
"stage": "智能整理失败",
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
"result_md": None,
|
||||
})
|
||||
_store_task_progress(task_id, state)
|
||||
|
||||
|
||||
@api_router.post("/parse_tasks/{task_id}", status_code=201)
|
||||
async def create_parse_task(task_id: str):
|
||||
"""Register a task before the multipart upload starts."""
|
||||
|
|
@ -383,6 +496,37 @@ async def get_parse_progress(task_id: str):
|
|||
return state
|
||||
|
||||
|
||||
@api_router.post("/mindmap_tasks/{task_id}", status_code=201)
|
||||
async def create_mindmap_task(task_id: str, request: MindmapOrganizeRequest):
|
||||
"""Create an async task that organizes Markdown into summarized mindmap Markdown."""
|
||||
markdown = request.markdown.strip()
|
||||
if not markdown:
|
||||
raise HTTPException(status_code=400, detail="Markdown content is required")
|
||||
|
||||
state = {
|
||||
"progress": 0,
|
||||
"stage": "等待智能整理",
|
||||
"status": "pending",
|
||||
"error": None,
|
||||
"file_names": "",
|
||||
"result_md": None,
|
||||
}
|
||||
_store_task_progress(task_id, state)
|
||||
asyncio.create_task(_run_mindmap_organize_task(task_id, markdown, request.mode))
|
||||
logger.info(f"Registered mindmap organize task pid={os.getpid()} task_id={task_id}")
|
||||
return state
|
||||
|
||||
|
||||
@api_router.get("/mindmap_progress/{task_id}")
|
||||
async def get_mindmap_progress(task_id: str):
|
||||
"""Query async mindmap organization progress and result."""
|
||||
state = _get_task_progress(task_id)
|
||||
if state is None:
|
||||
logger.warning(f"Mindmap task not found pid={os.getpid()} task_id={task_id}")
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return state
|
||||
|
||||
|
||||
@api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)])
|
||||
async def parse_pdf(
|
||||
files: List[UploadFile] = File(..., description="Upload pdf, image, or Word files for parsing"),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export interface ParseProgress {
|
|||
file_names: string
|
||||
}
|
||||
|
||||
export interface MindmapOrganizeProgress extends ParseProgress {
|
||||
result_md?: string | null
|
||||
}
|
||||
|
||||
export const documentApi = {
|
||||
/**
|
||||
* 解析文档
|
||||
|
|
@ -96,5 +100,20 @@ export const documentApi = {
|
|||
return request.get(`/api/parse_progress/${taskId}`).then(result => {
|
||||
return result as unknown as ParseProgress
|
||||
})
|
||||
},
|
||||
|
||||
createMindmapTask(taskId: string, markdown: string, mode = 'smart'): Promise<MindmapOrganizeProgress> {
|
||||
return request.post(`/api/mindmap_tasks/${encodeURIComponent(taskId)}`, {
|
||||
markdown,
|
||||
mode
|
||||
}).then(result => {
|
||||
return result as unknown as MindmapOrganizeProgress
|
||||
})
|
||||
},
|
||||
|
||||
getMindmapProgress(taskId: string): Promise<MindmapOrganizeProgress> {
|
||||
return request.get(`/api/mindmap_progress/${encodeURIComponent(taskId)}`).then(result => {
|
||||
return result as unknown as MindmapOrganizeProgress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,10 +141,26 @@
|
|||
|
||||
<div v-show="activeTab === 'source'" class="source-content">
|
||||
<div class="content-toolbar source-toolbar">
|
||||
<div v-if="templateRenderError" class="template-error-inline">
|
||||
{{ templateRenderError }}
|
||||
<div class="content-toolbar-info source-mode-toolbar">
|
||||
<el-segmented
|
||||
v-model="sourceContentMode"
|
||||
:options="sourceContentModeOptions"
|
||||
size="small"
|
||||
:disabled="sourceViewMode === 'ast' || isSmartOrganizing"
|
||||
/>
|
||||
<span v-if="isSmartOrganizing" class="smart-organize-status">
|
||||
{{ smartOrganizeStage || '智能整理中' }} {{ smartOrganizeProgress }}%
|
||||
</span>
|
||||
<span v-else-if="smartOrganizeError" class="template-error-inline">
|
||||
{{ smartOrganizeError }}
|
||||
</span>
|
||||
<span v-else-if="sourceContentMode === 'smart' && smartMarkdownContent" class="smart-organize-status">
|
||||
智能整理结果
|
||||
</span>
|
||||
<span v-else-if="templateRenderError" class="template-error-inline">
|
||||
{{ templateRenderError }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else></div>
|
||||
<div class="content-toolbar-actions">
|
||||
<el-button
|
||||
:type="hasActiveReplacementRules ? 'primary' : 'default'"
|
||||
|
|
@ -235,6 +251,7 @@ import { ElMessage } from 'element-plus'
|
|||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
||||
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
||||
import { documentApi } from '@/api/document'
|
||||
import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||
import { markdownToAstString } from '@/utils/markdownAst'
|
||||
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||
|
|
@ -300,16 +317,34 @@ const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
|
|||
const sourceViewMode = ref<'markdown' | 'ast'>(
|
||||
loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode)
|
||||
)
|
||||
const sourceContentMode = ref<'source' | 'smart'>('source')
|
||||
const sourceContentModeOptions = [
|
||||
{ label: '源码', value: 'source' },
|
||||
{ label: '智能整理', value: 'smart' }
|
||||
]
|
||||
const showReplacementDialog = ref(false)
|
||||
const isMobileDialog = ref(false)
|
||||
const replacementRules = ref<ReplacementRule[]>([])
|
||||
const draftReplacementRules = ref<ReplacementRule[]>([])
|
||||
const smartMarkdownContent = ref('')
|
||||
const smartOrganizeTaskId = ref('')
|
||||
const isSmartOrganizing = ref(false)
|
||||
const smartOrganizeProgress = ref(0)
|
||||
const smartOrganizeStage = ref('')
|
||||
const smartOrganizeError = ref('')
|
||||
let smartOrganizeTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_MODE_KEY, value))
|
||||
watch(sourceViewMode, (value) => storeValue(SOURCE_VIEW_MODE_KEY, value))
|
||||
|
||||
const manualMarkdownContent = computed(() => results.value?.source || '')
|
||||
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
||||
const activeSourceMarkdownContent = computed(() => {
|
||||
if (sourceContentMode.value === 'smart' && smartMarkdownContent.value) {
|
||||
return smartMarkdownContent.value
|
||||
}
|
||||
return manualMarkdownContent.value
|
||||
})
|
||||
const templateRenderResult = computed(() => renderMarkdownTemplate(activeSourceMarkdownContent.value, replacementRules.value))
|
||||
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
||||
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
|
||||
const templateRenderError = computed(() => templateRenderResult.value.error)
|
||||
|
|
@ -337,15 +372,105 @@ const sourcePanelContent = computed({
|
|||
if (sourceViewMode.value === 'ast') {
|
||||
return markdownToAstString(renderedMarkdownContent.value)
|
||||
}
|
||||
return manualMarkdownContent.value
|
||||
return activeSourceMarkdownContent.value
|
||||
},
|
||||
set: (value: string) => {
|
||||
if (sourceViewMode.value === 'markdown' && results.value) {
|
||||
if (sourceViewMode.value !== 'markdown') return
|
||||
if (sourceContentMode.value === 'smart') {
|
||||
smartMarkdownContent.value = value
|
||||
return
|
||||
}
|
||||
if (results.value) {
|
||||
results.value.source = value
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const stopSmartOrganizePolling = () => {
|
||||
if (smartOrganizeTimer) {
|
||||
clearInterval(smartOrganizeTimer)
|
||||
smartOrganizeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetSmartOrganizeState = () => {
|
||||
stopSmartOrganizePolling()
|
||||
sourceContentMode.value = 'source'
|
||||
smartMarkdownContent.value = ''
|
||||
smartOrganizeTaskId.value = ''
|
||||
isSmartOrganizing.value = false
|
||||
smartOrganizeProgress.value = 0
|
||||
smartOrganizeStage.value = ''
|
||||
smartOrganizeError.value = ''
|
||||
}
|
||||
|
||||
const startSmartOrganizePolling = (taskId: string) => {
|
||||
stopSmartOrganizePolling()
|
||||
smartOrganizeTimer = setInterval(async () => {
|
||||
try {
|
||||
const data = await documentApi.getMindmapProgress(taskId)
|
||||
smartOrganizeProgress.value = data.progress
|
||||
smartOrganizeStage.value = data.stage
|
||||
if (data.status === 'completed') {
|
||||
stopSmartOrganizePolling()
|
||||
isSmartOrganizing.value = false
|
||||
smartMarkdownContent.value = data.result_md || ''
|
||||
smartOrganizeError.value = ''
|
||||
sourceContentMode.value = 'smart'
|
||||
ElMessage.success('智能整理完成')
|
||||
} else if (data.status === 'failed') {
|
||||
stopSmartOrganizePolling()
|
||||
isSmartOrganizing.value = false
|
||||
sourceContentMode.value = 'source'
|
||||
smartOrganizeError.value = data.error || '智能整理失败'
|
||||
ElMessage.error(smartOrganizeError.value)
|
||||
}
|
||||
} catch (err) {
|
||||
stopSmartOrganizePolling()
|
||||
isSmartOrganizing.value = false
|
||||
sourceContentMode.value = 'source'
|
||||
smartOrganizeError.value = (err as Error).message || '智能整理进度查询失败'
|
||||
ElMessage.error(smartOrganizeError.value)
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const startSmartOrganize = async () => {
|
||||
const markdown = manualMarkdownContent.value.trim()
|
||||
if (!markdown) {
|
||||
sourceContentMode.value = 'source'
|
||||
ElMessage.warning('暂无 Markdown 源码可整理')
|
||||
return
|
||||
}
|
||||
if (smartMarkdownContent.value) {
|
||||
sourceContentMode.value = 'smart'
|
||||
return
|
||||
}
|
||||
|
||||
const taskId = `mindmap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
smartOrganizeTaskId.value = taskId
|
||||
isSmartOrganizing.value = true
|
||||
smartOrganizeProgress.value = 0
|
||||
smartOrganizeStage.value = '创建智能整理任务'
|
||||
smartOrganizeError.value = ''
|
||||
|
||||
try {
|
||||
await documentApi.createMindmapTask(taskId, markdown, 'smart')
|
||||
startSmartOrganizePolling(taskId)
|
||||
} catch (err) {
|
||||
isSmartOrganizing.value = false
|
||||
sourceContentMode.value = 'source'
|
||||
smartOrganizeError.value = (err as Error).message || '智能整理任务创建失败'
|
||||
ElMessage.error(smartOrganizeError.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(sourceContentMode, (value) => {
|
||||
if (value === 'smart') {
|
||||
startSmartOrganize()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleSettings = () => {
|
||||
if (showSettings.value) {
|
||||
const settingsPanel = document.querySelector('.settings-panel')
|
||||
|
|
@ -410,6 +535,7 @@ const applyConfig = (nextConfig: typeof config) => {
|
|||
}
|
||||
|
||||
const handleProcessDocument = async () => {
|
||||
resetSmartOrganizeState()
|
||||
await processDocument()
|
||||
if (error.value) {
|
||||
ElMessage.error(error.value)
|
||||
|
|
@ -465,13 +591,18 @@ const saveReplacementRules = () => {
|
|||
|
||||
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
|
||||
if (!results.value) return
|
||||
const updated = replaceFirstMindmapText(results.value.source || results.value.markdown, oldText, newText)
|
||||
if (updated === (results.value.source || results.value.markdown)) {
|
||||
const currentMarkdown = activeSourceMarkdownContent.value
|
||||
const updated = replaceFirstMindmapText(currentMarkdown, oldText, newText)
|
||||
if (updated === currentMarkdown) {
|
||||
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
|
||||
return
|
||||
}
|
||||
results.value.source = updated
|
||||
results.value.markdown = updated
|
||||
if (sourceContentMode.value === 'smart') {
|
||||
smartMarkdownContent.value = updated
|
||||
} else {
|
||||
results.value.source = updated
|
||||
results.value.markdown = updated
|
||||
}
|
||||
ElMessage.success('节点已同步到 Markdown')
|
||||
}
|
||||
|
||||
|
|
@ -485,6 +616,7 @@ const handleFileInputChange = async (event: Event) => {
|
|||
await handleFileUpload(input.files)
|
||||
input.value = ''
|
||||
if (uploadedFiles.value.length > 0) {
|
||||
resetSmartOrganizeState()
|
||||
isUploadAreaCollapsed.value = true
|
||||
initializeManualResult()
|
||||
markdownRenderMode.value = 'markdown'
|
||||
|
|
@ -501,6 +633,7 @@ const handleDrop = async (event: DragEvent) => {
|
|||
if (event.dataTransfer?.files) {
|
||||
await handleFileUpload(event.dataTransfer.files)
|
||||
if (uploadedFiles.value.length > 0) {
|
||||
resetSmartOrganizeState()
|
||||
isUploadAreaCollapsed.value = true
|
||||
initializeManualResult()
|
||||
markdownRenderMode.value = 'markdown'
|
||||
|
|
@ -518,6 +651,7 @@ const removeFile = (index: number) => {
|
|||
}
|
||||
|
||||
const clearAllFiles = () => {
|
||||
resetSmartOrganizeState()
|
||||
clearAll()
|
||||
isUploadAreaCollapsed.value = false
|
||||
}
|
||||
|
|
@ -528,6 +662,7 @@ onMounted(() => {
|
|||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSmartOrganizePolling()
|
||||
window.removeEventListener('resize', updateViewportState)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -957,6 +1092,16 @@ onUnmounted(() => {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.source-mode-toolbar {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.smart-organize-status {
|
||||
font-size: 12px;
|
||||
color: #86909C;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content-toolbar-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue