diff --git a/apps/admin-web/src/components/ArticleEditorCanvas.vue b/apps/admin-web/src/components/ArticleEditorCanvas.vue index dd6ee87..49cd6ca 100644 --- a/apps/admin-web/src/components/ArticleEditorCanvas.vue +++ b/apps/admin-web/src/components/ArticleEditorCanvas.vue @@ -17,19 +17,19 @@ import { PictureOutlined, RedoOutlined, TableOutlined, - UndoOutlined, UnorderedListOutlined, } from "@ant-design/icons-vue"; import { message } from "ant-design-vue"; -import { commandsCtx, editorViewCtx } from "@milkdown/kit/core"; +import { commandsCtx, editorViewCtx, parserCtx } from "@milkdown/kit/core"; import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history"; -import { NodeSelection, type Selection } from "@milkdown/kit/prose/state"; +import { Slice } from "@milkdown/kit/prose/model"; +import { NodeSelection, TextSelection, type Selection } from "@milkdown/kit/prose/state"; +import type { EditorView } from "@milkdown/kit/prose/view"; import { addBlockTypeCommand, insertHrCommand, toggleEmphasisCommand, toggleStrongCommand, - toggleInlineCodeCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInHeadingCommand, @@ -56,6 +56,15 @@ import { Milkdown, useEditor, useInstance } from "@milkdown/vue"; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; +import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue"; +import EditorAiAssistPanel from "@/components/editor-common/EditorAiAssistPanel.vue"; +import EditorGridSizePicker from "@/components/editor-common/EditorGridSizePicker.vue"; +import EditorSelectionFrame from "@/components/editor-common/EditorSelectionFrame.vue"; +import MarkdownPreview from "@/components/MarkdownPreview.vue"; +import { + streamArticleSelectionOptimize, + type ArticleSelectionOptimizeStreamEvent, +} from "@/lib/api"; import { formatError } from "@/lib/errors"; import { richImageBlockFeature, @@ -64,6 +73,7 @@ import { } from "@/lib/milkdown/richImageBlock"; const props = defineProps<{ + articleId: number; title: string; modelValue: string; disabled?: boolean; @@ -80,18 +90,22 @@ const crepeRef = ref(null); const syncingExternalMarkdown = ref(false); const surfaceRef = ref(null); const tablePickerRef = ref(null); -const tableGridRef = ref(null); -const tableContextMenuRef = ref(null); -const imageContextMenuRef = ref(null); const imageInputRef = ref(null); -const TABLE_PICKER_CELL_SIZE = 22; -const TABLE_PICKER_GAP = 6; -const TABLE_PICKER_INITIAL_ROWS = 4; -const TABLE_PICKER_INITIAL_COLS = 10; -const TABLE_PICKER_MAX_ROWS = 15; -const TABLE_PICKER_MAX_COLS = 15; -const TABLE_PICKER_EXPAND_STEP = 2; +const AI_OPTIMIZE_PANEL_GAP = 18; +const AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME = "article-editor-ai-optimize-selection"; +const AI_OPTIMIZE_TOOLBAR_ICON = ` + +`; type TableContextMenuAction = | "add-row-before" @@ -116,6 +130,8 @@ type ImageContextMenuAction = | "align-right" | "delete-image"; +type AiOptimizeStatus = "idle" | "generating" | "completed" | "error"; + type TableContextMenuState = { open: boolean; x: number; @@ -139,6 +155,23 @@ type SelectedImageState = { align: RichImageAlign; }; +type AiOptimizePanelState = { + open: boolean; + x: number; + y: number; + width: number; + from: number; + to: number; + selectedText: string; +}; + +type TextSelectionSnapshot = { + from: number; + to: number; + text: string; + rect: DOMRect; +}; + type ImageContextMenuState = { open: boolean; x: number; @@ -146,7 +179,6 @@ type ImageContextMenuState = { nodePos: number; nodeSize: number; src: string; - align: RichImageAlign; }; type SelectedImageSnapshot = { @@ -195,7 +227,18 @@ function createDefaultImageContextMenuState(): ImageContextMenuState { nodePos: 0, nodeSize: 0, src: "", - align: "center", + }; +} + +function createDefaultAiOptimizePanelState(): AiOptimizePanelState { + return { + open: false, + x: 0, + y: 0, + width: 0, + from: 0, + to: 0, + selectedText: "", }; } @@ -211,6 +254,19 @@ const { loading } = useEditor((root) => { [CrepeFeature.BlockEdit]: true, [CrepeFeature.Placeholder]: false, }, + featureConfigs: { + [CrepeFeature.Toolbar]: { + buildToolbar: (builder) => { + builder.addGroup("ai-optimize", "AI Optimize").addItem("ai-optimize", { + icon: AI_OPTIMIZE_TOOLBAR_ICON, + active: () => false, + onRun: (ctx) => { + openAiOptimizePanel(ctx); + }, + }); + }, + }, + }, }); crepe.addFeature(richImageBlockFeature, { onUpload: props.uploadImage, @@ -241,16 +297,16 @@ const { loading } = useEditor((root) => { const [instanceLoading, getEditor] = useInstance(); const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value); const tablePickerOpen = ref(false); -const tablePickerRows = ref(TABLE_PICKER_INITIAL_ROWS); -const tablePickerCols = ref(TABLE_PICKER_INITIAL_COLS); -const tablePreviewRow = ref(0); -const tablePreviewCol = ref(0); -const tablePointerActive = ref(false); const imageUploading = ref(false); const imageInputAction = ref<"insert" | "replace">("insert"); const tableContextMenu = ref(createDefaultTableContextMenuState()); const selectedImage = ref(createDefaultSelectedImageState()); const imageContextMenu = ref(createDefaultImageContextMenuState()); +const aiOptimizePanel = ref(createDefaultAiOptimizePanelState()); +const aiOptimizeInstruction = ref(""); +const aiOptimizePreview = ref(""); +const aiOptimizeError = ref(""); +const aiOptimizeStatus = ref("idle"); let selectedImageHostElement: HTMLElement | null = null; let selectedImageWrapperElement: HTMLElement | null = null; @@ -259,49 +315,140 @@ let selectedImageSyncFrame = 0; let imageDragActive = false; let selectedImageResizeObserver: ResizeObserver | null = null; let selectedImageLoadHandler: (() => void) | null = null; - -const tablePickerGridStyle = computed(() => ({ - gridTemplateColumns: `repeat(${tablePickerCols.value}, ${TABLE_PICKER_CELL_SIZE}px)`, -})); - -const tablePickerCells = computed(() => { - const cells: Array<{ key: string; row: number; col: number }> = []; - - for (let row = 1; row <= tablePickerRows.value; row += 1) { - for (let col = 1; col <= tablePickerCols.value; col += 1) { - cells.push({ key: `${row}-${col}`, row, col }); - } - } - - return cells; -}); - -const tableSelectionLabel = computed(() => { - if (!tablePreviewRow.value || !tablePreviewCol.value) { - return t("article.editor.tablePicker.empty"); - } - - return `${tablePreviewRow.value} × ${tablePreviewCol.value}`; -}); - -const tableContextMenuStyle = computed(() => ({ - left: `${tableContextMenu.value.x}px`, - top: `${tableContextMenu.value.y}px`, -})); - -const selectedImageFrameStyle = computed(() => ({ - left: `${selectedImage.value.x}px`, - top: `${selectedImage.value.y}px`, - width: `${selectedImage.value.width}px`, - height: `${selectedImage.value.height}px`, -})); - -const imageContextMenuStyle = computed(() => ({ - left: `${imageContextMenu.value.x}px`, - top: `${imageContextMenu.value.y}px`, -})); +let aiOptimizeAbortController: AbortController | null = null; +let aiOptimizeIgnoreNextWindowPointerDown = false; const tableContextHeaderRow = computed(() => tableContextMenu.value.rowIndex === 0); +const aiOptimizeStreaming = computed(() => aiOptimizeStatus.value === "generating"); +const aiOptimizeHasPreview = computed(() => aiOptimizePreview.value.trim().length > 0); +const aiOptimizeUiText = computed(() => ({ + title: t("article.editor.aiOptimize.title"), + promptPlaceholder: t("article.editor.aiOptimize.promptPlaceholder"), + generating: t("article.editor.aiOptimize.generating"), + failed: t("article.editor.aiOptimize.messages.failed"), + disclaimer: t("article.editor.aiOptimize.disclaimer"), + regenerate: t("article.editor.aiOptimize.regenerate"), + close: t("article.editor.aiOptimize.close"), + replace: t("article.editor.aiOptimize.replace"), +})); + +const tableContextMenuGroups = computed(() => [ + { + key: "rows", + columns: 4, + items: [ + { + key: "add-row-before", + label: t("article.editor.tableMenu.addRowBefore"), + icon: InsertRowAboveOutlined, + disabled: tableContextHeaderRow.value, + }, + { + key: "add-row-after", + label: t("article.editor.tableMenu.addRowAfter"), + icon: InsertRowBelowOutlined, + }, + { + key: "delete-row", + label: t("article.editor.tableMenu.deleteRow"), + icon: DeleteOutlined, + danger: true, + disabled: tableContextHeaderRow.value, + }, + { + key: "delete-table", + label: t("article.editor.tableMenu.deleteTable"), + icon: TableOutlined, + danger: true, + }, + ], + }, + { + key: "columns", + columns: 6, + items: [ + { + key: "add-col-before", + label: t("article.editor.tableMenu.addColBefore"), + icon: InsertRowLeftOutlined, + }, + { + key: "add-col-after", + label: t("article.editor.tableMenu.addColAfter"), + icon: InsertRowRightOutlined, + }, + { + key: "delete-col", + label: t("article.editor.tableMenu.deleteCol"), + icon: DeleteOutlined, + danger: true, + }, + { + key: "align-left", + label: t("article.editor.tableMenu.alignLeft"), + icon: AlignLeftOutlined, + }, + { + key: "align-center", + label: t("article.editor.tableMenu.alignCenter"), + icon: AlignCenterOutlined, + }, + { + key: "align-right", + label: t("article.editor.tableMenu.alignRight"), + icon: AlignRightOutlined, + }, + ], + }, +]); + +const imageContextMenuGroups = computed(() => [ + { + key: "image", + columns: 3, + items: [ + { + key: "replace-image", + label: t("article.editor.imageMenu.replace"), + icon: PictureOutlined, + }, + { + key: "toggle-caption", + label: t("article.editor.imageMenu.caption"), + icon: CodeOutlined, + }, + { + key: "align-left", + label: t("article.editor.imageMenu.alignLeft"), + icon: AlignLeftOutlined, + active: selectedImage.value.align === "left", + }, + { + key: "align-center", + label: t("article.editor.imageMenu.alignCenter"), + icon: AlignCenterOutlined, + active: selectedImage.value.align === "center", + }, + { + key: "align-right", + label: t("article.editor.imageMenu.alignRight"), + icon: AlignRightOutlined, + active: selectedImage.value.align === "right", + }, + { + key: "reset-size", + label: t("article.editor.imageMenu.resetSize"), + icon: RedoOutlined, + }, + { + key: "delete-image", + label: t("article.editor.imageMenu.delete"), + icon: DeleteOutlined, + danger: true, + }, + ], + }, +]); function syncMarkdownFromProps(nextValue: string): void { const editor = getEditor(); @@ -317,6 +464,7 @@ function syncMarkdownFromProps(nextValue: string): void { clearSelectedImageState(); closeImageContextMenu(); + closeAiOptimizePanel(); syncingExternalMarkdown.value = true; editor.action(replaceAll(resolvedValue, true)); queueMicrotask(() => { @@ -333,6 +481,7 @@ watch( closeTableContextMenu(); clearSelectedImageState(); closeImageContextMenu(); + closeAiOptimizePanel(); } }, { immediate: true }, @@ -351,23 +500,22 @@ watch( ); onBeforeUnmount(() => { + stopAiOptimizeGeneration(); if (selectedImageSyncFrame) { cancelAnimationFrame(selectedImageSyncFrame); selectedImageSyncFrame = 0; } detachSelectedImageDomSync(); window.removeEventListener("pointerdown", handleWindowPointerDown); - window.removeEventListener("pointermove", handleWindowPointerMove); - window.removeEventListener("pointerup", handleWindowPointerUp); window.removeEventListener("resize", handleWindowResize); + window.removeEventListener("keydown", handleWindowKeydown); crepeRef.value = null; }); onMounted(() => { window.addEventListener("pointerdown", handleWindowPointerDown); - window.addEventListener("pointermove", handleWindowPointerMove); - window.addEventListener("pointerup", handleWindowPointerUp); window.addEventListener("resize", handleWindowResize); + window.addEventListener("keydown", handleWindowKeydown); }); function runCommand(command: { key: unknown }, payload?: unknown): void { @@ -489,17 +637,8 @@ function resolveImageAlign(value: unknown): RichImageAlign { return "center"; } -function resetTablePicker(): void { - tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS; - tablePickerCols.value = TABLE_PICKER_INITIAL_COLS; - tablePreviewRow.value = 0; - tablePreviewCol.value = 0; - tablePointerActive.value = false; -} - function closeTablePicker(): void { tablePickerOpen.value = false; - resetTablePicker(); } function closeTableContextMenu(): void { @@ -537,7 +676,6 @@ function queueSelectedImageBoundsSync(): void { syncSelectedImageBoundsFromDom(); requestAnimationFrame(() => { syncSelectedImageBoundsFromDom(); - adjustImageContextMenuPosition(); }); }); } @@ -557,7 +695,6 @@ function attachSelectedImageDomSync(): void { if (typeof ResizeObserver !== "undefined") { selectedImageResizeObserver = new ResizeObserver(() => { syncSelectedImageBoundsFromDom(); - adjustImageContextMenuPosition(); }); selectedImageResizeObserver.observe(selectedImageElement); } @@ -577,6 +714,336 @@ function closeImageContextMenu(): void { imageContextMenu.value = createDefaultImageContextMenuState(); } +function resolveEditorDOMRange(view: EditorView, from: number, to: number): Range | null { + try { + const start = view.domAtPos(from); + const end = view.domAtPos(to); + const range = view.dom.ownerDocument.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset); + return range; + } catch { + return null; + } +} + +function getCSSHighlightsRegistry(): + | { + set: (name: string, value: unknown) => void; + delete: (name: string) => void; + } + | null { + const cssWithHighlights = globalThis.CSS as typeof globalThis.CSS & { + highlights?: { + set: (name: string, value: unknown) => void; + delete: (name: string) => void; + }; + }; + + return cssWithHighlights.highlights ?? null; +} + +function clearAiOptimizeSelectionHighlight(): void { + getCSSHighlightsRegistry()?.delete(AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME); +} + +function syncAiOptimizeSelectionHighlight(from: number, to: number): void { + clearAiOptimizeSelectionHighlight(); + + const registry = getCSSHighlightsRegistry(); + const HighlightCtor = window.Highlight as (new (...ranges: Range[]) => unknown) | undefined; + if (!registry || !HighlightCtor) { + return; + } + + const editor = getEditor(); + if (!editor) { + return; + } + + editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const range = resolveEditorDOMRange(view, from, to); + if (!range) { + return; + } + + registry.set(AI_OPTIMIZE_SELECTION_HIGHLIGHT_NAME, new HighlightCtor(range)); + }); +} + +function resetAiOptimizeOutput(): void { + aiOptimizePreview.value = ""; + aiOptimizeError.value = ""; + aiOptimizeStatus.value = "idle"; +} + +function stopAiOptimizeGeneration(): void { + aiOptimizeAbortController?.abort(); + aiOptimizeAbortController = null; + + if (aiOptimizeStatus.value === "generating") { + aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "idle"; + aiOptimizeError.value = ""; + } +} + +function closeAiOptimizePanel(): void { + stopAiOptimizeGeneration(); + clearAiOptimizeSelectionHighlight(); + aiOptimizePanel.value = createDefaultAiOptimizePanelState(); + aiOptimizeInstruction.value = ""; + resetAiOptimizeOutput(); +} + +function resolveTextSelectionSnapshot(ctx: Ctx): TextSelectionSnapshot | null { + const view = ctx.get(editorViewCtx); + const { selection, doc } = view.state; + if (!(selection instanceof TextSelection) || selection.empty) { + return null; + } + + const text = doc.textBetween(selection.from, selection.to, "\n\n"); + if (!text.trim()) { + return null; + } + + const range = resolveEditorDOMRange(view, selection.from, selection.to); + const rangeRects = range + ? Array.from(range.getClientRects()).filter((rect) => rect.width > 0 || rect.height > 0) + : []; + const rangeRect = + rangeRects.length > 0 + ? rangeRects.reduce( + (combined, rect) => { + const left = Math.min(combined.left, rect.left); + const top = Math.min(combined.top, rect.top); + const right = Math.max(combined.right, rect.right); + const bottom = Math.max(combined.bottom, rect.bottom); + return new DOMRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1)); + }, + new DOMRect( + rangeRects[0].left, + rangeRects[0].top, + Math.max(rangeRects[0].width, 1), + Math.max(rangeRects[0].height, 1), + ), + ) + : range?.getBoundingClientRect(); + const rect = + rangeRect && (rangeRect.width > 0 || rangeRect.height > 0) + ? rangeRect + : (() => { + const start = view.coordsAtPos(selection.from); + const end = view.coordsAtPos(selection.to); + const left = Math.min(start.left, end.left); + const right = Math.max(start.right, end.right); + const top = Math.min(start.top, end.top); + const bottom = Math.max(start.bottom, end.bottom); + return new DOMRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1)); + })(); + + return { + from: selection.from, + to: selection.to, + text, + rect, + }; +} + +function resolveAiOptimizePanelFrame(anchorRect: DOMRect): { x: number; y: number; width: number } { + const viewportPadding = 16; + const panelPadding = 24; + const maxWidth = Math.max(240, window.innerWidth - viewportPadding * 2); + const preferredWidth = 1120; + + const surfaceRect = surfaceRef.value?.getBoundingClientRect(); + const widthFromSurface = surfaceRect ? Math.max(240, surfaceRect.width - panelPadding * 2) : preferredWidth; + const width = Math.min(maxWidth, preferredWidth, widthFromSurface); + + return { + x: anchorRect.left + anchorRect.width / 2, + y: Math.max(viewportPadding, anchorRect.bottom + AI_OPTIMIZE_PANEL_GAP), + width, + }; +} + +function openAiOptimizePanel(ctx: Ctx): void { + if (editorDisabled.value) { + return; + } + + const snapshot = resolveTextSelectionSnapshot(ctx); + if (!snapshot) { + message.warning(t("article.editor.aiOptimize.messages.selectionRequired")); + return; + } + + closeTablePicker(); + closeTableContextMenu(); + clearSelectedImageState(); + closeImageContextMenu(); + stopAiOptimizeGeneration(); + const frame = resolveAiOptimizePanelFrame(snapshot.rect); + + aiOptimizePanel.value = { + open: true, + x: frame.x, + y: frame.y, + width: frame.width, + from: snapshot.from, + to: snapshot.to, + selectedText: snapshot.text, + }; + aiOptimizeInstruction.value = ""; + resetAiOptimizeOutput(); + aiOptimizeIgnoreNextWindowPointerDown = true; + + window.setTimeout(() => { + aiOptimizeIgnoreNextWindowPointerDown = false; + }, 0); + + void nextTick(() => { + syncAiOptimizeSelectionHighlight(snapshot.from, snapshot.to); + }); +} + +function handleAiOptimizeStreamEvent(event: ArticleSelectionOptimizeStreamEvent): void { + if (event.type === "start") { + aiOptimizeStatus.value = "generating"; + aiOptimizeError.value = ""; + return; + } + + if (event.type === "delta") { + aiOptimizeStatus.value = "generating"; + aiOptimizeError.value = ""; + if (typeof event.content === "string") { + aiOptimizePreview.value = event.content; + } else if (typeof event.delta === "string") { + aiOptimizePreview.value += event.delta; + } + return; + } + + if (event.type === "completed") { + aiOptimizeStatus.value = "completed"; + aiOptimizeError.value = ""; + if (typeof event.content === "string") { + aiOptimizePreview.value = event.content; + } + return; + } + + if (event.type === "error") { + aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error"; + aiOptimizeError.value = event.error || t("article.editor.aiOptimize.messages.failed"); + if (typeof event.content === "string" && event.content.trim()) { + aiOptimizePreview.value = event.content; + } + if (!aiOptimizeHasPreview.value) { + message.error(aiOptimizeError.value); + } + } +} + +async function generateAiOptimizePreview(): Promise { + if (!aiOptimizePanel.value.open || editorDisabled.value) { + return; + } + + const instruction = aiOptimizeInstruction.value.trim(); + if (!instruction) { + message.warning(t("article.editor.aiOptimize.messages.promptRequired")); + return; + } + + stopAiOptimizeGeneration(); + aiOptimizePreview.value = ""; + aiOptimizeError.value = ""; + aiOptimizeStatus.value = "generating"; + + const controller = new AbortController(); + aiOptimizeAbortController = controller; + + try { + await streamArticleSelectionOptimize( + props.articleId, + { + title: props.title, + markdown_content: props.modelValue, + selected_text: aiOptimizePanel.value.selectedText, + instruction, + }, + { + onEvent: handleAiOptimizeStreamEvent, + }, + controller.signal, + ); + + if (!controller.signal.aborted && aiOptimizeStatus.value === "generating") { + aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error"; + } + } catch (error) { + if (controller.signal.aborted) { + return; + } + + aiOptimizeStatus.value = aiOptimizeHasPreview.value ? "completed" : "error"; + aiOptimizeError.value = formatError(error); + if (!aiOptimizeHasPreview.value) { + message.error(aiOptimizeError.value); + } + } finally { + if (aiOptimizeAbortController === controller) { + aiOptimizeAbortController = null; + } + } +} + +function applyAiOptimizeReplacement(): void { + const replacement = aiOptimizePreview.value.trim(); + if (!aiOptimizePanel.value.open || !replacement) { + if (!replacement) { + message.warning(t("article.editor.aiOptimize.messages.empty")); + } + return; + } + + const target = { ...aiOptimizePanel.value }; + let replaced = false; + + runEditorAction((ctx) => { + const view = ctx.get(editorViewCtx); + const currentText = view.state.doc.textBetween(target.from, target.to, "\n\n"); + if (currentText !== target.selectedText) { + message.warning(t("article.editor.aiOptimize.messages.selectionChanged")); + return; + } + + const parser = ctx.get(parserCtx); + const selection = TextSelection.create(view.state.doc, target.from, target.to); + + try { + const replacementDoc = parser(replacement); + const replacementSlice = Slice.maxOpen(replacementDoc.content); + const transaction = view.state.tr.setSelection(selection).replaceSelection(replacementSlice).scrollIntoView(); + view.dispatch(transaction); + } catch { + view.dispatch(view.state.tr.insertText(replacement, target.from, target.to).scrollIntoView()); + } + + replaced = true; + requestAnimationFrame(() => { + view.focus(); + }); + }); + + if (replaced) { + closeAiOptimizePanel(); + } +} + function resolveSelectedImageSnapshot(targetSelection?: Selection | null): SelectedImageSnapshot | null { const editor = getEditor(); if (!editor) { @@ -717,52 +1184,6 @@ function queueSelectedImageSync(): void { }); } -function ensureTablePickerCapacity(row: number, col: number): void { - while (row > tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) { - tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP); - } - - while (col > tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) { - tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP); - } - - if (row === tablePickerRows.value && tablePickerRows.value < TABLE_PICKER_MAX_ROWS) { - tablePickerRows.value = Math.min(TABLE_PICKER_MAX_ROWS, tablePickerRows.value + TABLE_PICKER_EXPAND_STEP); - } - - if (col === tablePickerCols.value && tablePickerCols.value < TABLE_PICKER_MAX_COLS) { - tablePickerCols.value = Math.min(TABLE_PICKER_MAX_COLS, tablePickerCols.value + TABLE_PICKER_EXPAND_STEP); - } -} - -function updateTablePreview(row: number, col: number): void { - const nextRow = clamp(row, 1, TABLE_PICKER_MAX_ROWS); - const nextCol = clamp(col, 1, TABLE_PICKER_MAX_COLS); - - ensureTablePickerCapacity(nextRow, nextCol); - - tablePreviewRow.value = nextRow; - tablePreviewCol.value = nextCol; -} - -function resolveTableSizeFromPointer(clientX: number, clientY: number): { row: number; col: number } | null { - const grid = tableGridRef.value; - if (!grid) { - return null; - } - - const rect = grid.getBoundingClientRect(); - const stepX = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP; - const stepY = TABLE_PICKER_CELL_SIZE + TABLE_PICKER_GAP; - const relativeX = clientX - rect.left; - const relativeY = clientY - rect.top; - - return { - row: clamp(Math.ceil((relativeY + TABLE_PICKER_GAP) / stepY), 1, TABLE_PICKER_MAX_ROWS), - col: clamp(Math.ceil((relativeX + TABLE_PICKER_GAP) / stepX), 1, TABLE_PICKER_MAX_COLS), - }; -} - function insertTableFromSelection(row: number, col: number): void { runCommand(insertTableCommand, { row, col }); closeTablePicker(); @@ -779,21 +1200,20 @@ function toggleTablePicker(): void { } closeTableContextMenu(); - resetTablePicker(); tablePickerOpen.value = true; } -function beginTableSelection(row: number, col: number): void { - if (editorDisabled.value) { +function handleWindowPointerDown(event: PointerEvent): void { + if (aiOptimizeIgnoreNextWindowPointerDown) { return; } - tablePointerActive.value = true; - updateTablePreview(row, col); -} - -function handleWindowPointerDown(event: PointerEvent): void { - if (!tablePickerOpen.value && !tableContextMenu.value.open && !imageContextMenu.value.open) { + if ( + !tablePickerOpen.value && + !tableContextMenu.value.open && + !imageContextMenu.value.open && + !(aiOptimizePanel.value.open && aiOptimizeStatus.value === "idle") + ) { return; } @@ -802,14 +1222,6 @@ function handleWindowPointerDown(event: PointerEvent): void { if (tablePickerOpen.value && tablePickerRef.value?.contains(target)) { return; } - - if (tableContextMenu.value.open && tableContextMenuRef.value?.contains(target)) { - return; - } - - if (imageContextMenu.value.open && imageContextMenuRef.value?.contains(target)) { - return; - } } if (tablePickerOpen.value) { @@ -823,34 +1235,10 @@ function handleWindowPointerDown(event: PointerEvent): void { if (imageContextMenu.value.open) { closeImageContextMenu(); } -} -function handleWindowPointerMove(event: PointerEvent): void { - if (!tablePickerOpen.value || !tablePointerActive.value) { - return; + if (aiOptimizePanel.value.open && aiOptimizeStatus.value === "idle") { + closeAiOptimizePanel(); } - - const nextSize = resolveTableSizeFromPointer(event.clientX, event.clientY); - if (!nextSize) { - return; - } - - updateTablePreview(nextSize.row, nextSize.col); -} - -function handleWindowPointerUp(): void { - if (!tablePointerActive.value) { - return; - } - - tablePointerActive.value = false; - - if (!tablePreviewRow.value || !tablePreviewCol.value) { - closeTablePicker(); - return; - } - - insertTableFromSelection(tablePreviewRow.value, tablePreviewCol.value); } function resolveTableContextFromPointer(clientX: number, clientY: number): Omit | null { @@ -901,40 +1289,6 @@ function resolveTableContextFromPointer(clientX: number, clientY: number): Omit< return context; } -function adjustTableContextMenuPosition(): void { - const menu = tableContextMenuRef.value; - if (!menu || !tableContextMenu.value.open) { - return; - } - - const offset = 16; - const maxX = Math.max(offset, window.innerWidth - menu.offsetWidth - offset); - const maxY = Math.max(offset, window.innerHeight - menu.offsetHeight - offset); - - tableContextMenu.value = { - ...tableContextMenu.value, - x: clamp(tableContextMenu.value.x, offset, maxX), - y: clamp(tableContextMenu.value.y, offset, maxY), - }; -} - -function adjustImageContextMenuPosition(): void { - const menu = imageContextMenuRef.value; - if (!menu || !imageContextMenu.value.open) { - return; - } - - const offset = 16; - const maxX = Math.max(offset, window.innerWidth - menu.offsetWidth - offset); - const maxY = Math.max(offset, window.innerHeight - menu.offsetHeight - offset); - - imageContextMenu.value = { - ...imageContextMenu.value, - x: clamp(imageContextMenu.value.x, offset, maxX), - y: clamp(imageContextMenu.value.y, offset, maxY), - }; -} - function openTableContextMenu(event: MouseEvent): void { const context = resolveTableContextFromPointer(event.clientX, event.clientY); if (!context) { @@ -949,10 +1303,6 @@ function openTableContextMenu(event: MouseEvent): void { y: event.clientY, ...context, }; - - void nextTick(() => { - adjustTableContextMenuPosition(); - }); } function resolveImageContextFromTarget(target: EventTarget | null): SelectedImageSnapshot | null { @@ -1023,12 +1373,7 @@ function openImageContextMenu(event: MouseEvent): void { nodePos: snapshot.nodePos, nodeSize: snapshot.nodeSize, src: snapshot.src, - align: snapshot.align, }; - - void nextTick(() => { - adjustImageContextMenuPosition(); - }); } function handleEditorDragStart(event: DragEvent): void { @@ -1181,6 +1526,14 @@ function runImageContextAction(action: ImageContextMenuAction): void { } } +function handleTableContextMenuSelect(action: string): void { + runTableContextAction(action as TableContextMenuAction); +} + +function handleImageContextMenuSelect(action: string): void { + runImageContextAction(action as ImageContextMenuAction); +} + function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle): void { if (editorDisabled.value || !selectedImageElement || !selectedImage.value.open) { return; @@ -1283,12 +1636,27 @@ function handleSurfaceScroll(): void { closeTableContextMenu(); closeImageContextMenu(); syncSelectedImageBoundsFromDom(); + if (aiOptimizePanel.value.open) { + syncAiOptimizeSelectionHighlight(aiOptimizePanel.value.from, aiOptimizePanel.value.to); + } } function handleWindowResize(): void { - adjustTableContextMenuPosition(); - adjustImageContextMenuPosition(); syncSelectedImageBoundsFromDom(); + if (aiOptimizePanel.value.open) { + syncAiOptimizeSelectionHighlight(aiOptimizePanel.value.from, aiOptimizePanel.value.to); + } +} + +function handleWindowKeydown(event: KeyboardEvent): void { + if (!aiOptimizePanel.value.open) { + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + closeAiOptimizePanel(); + } } function runTableContextAction(action: TableContextMenuAction): void { @@ -1375,7 +1743,13 @@ function runTableContextAction(action: TableContextMenuAction): void { diff --git a/apps/admin-web/src/components/editor-common/EditorActionMenu.vue b/apps/admin-web/src/components/editor-common/EditorActionMenu.vue new file mode 100644 index 0000000..c731aed --- /dev/null +++ b/apps/admin-web/src/components/editor-common/EditorActionMenu.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue b/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue new file mode 100644 index 0000000..f97a111 --- /dev/null +++ b/apps/admin-web/src/components/editor-common/EditorAiAssistPanel.vue @@ -0,0 +1,586 @@ + + + + + diff --git a/apps/admin-web/src/components/editor-common/EditorGridSizePicker.vue b/apps/admin-web/src/components/editor-common/EditorGridSizePicker.vue new file mode 100644 index 0000000..3dee5ff --- /dev/null +++ b/apps/admin-web/src/components/editor-common/EditorGridSizePicker.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/apps/admin-web/src/components/editor-common/EditorSelectionFrame.vue b/apps/admin-web/src/components/editor-common/EditorSelectionFrame.vue new file mode 100644 index 0000000..ab07f41 --- /dev/null +++ b/apps/admin-web/src/components/editor-common/EditorSelectionFrame.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 46cb133..baf4d16 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -53,7 +53,7 @@ const enUS = { articleCreation: "Article Creation", templates: "Templates", custom: "Custom Generation", - optimize: "Optimization", + freeCreate: "Free Create", media: "Media", brandManagement: "Brand Management", brands: "Brand Library", @@ -106,9 +106,9 @@ const enUS = { title: "Custom Generation", description: "Generate articles with custom prompt rules, instant or scheduled.", }, - optimize: { - title: "Article Optimization", - description: "No matching backend APIs exist in this repository yet.", + freeCreate: { + title: "Free Create", + description: "Create and edit articles freely, no templates or AI generation required.", }, media: { title: "Media Management", @@ -209,8 +209,32 @@ const enUS = { custom_generation: "Custom generation", instant_task: "Instant task", schedule_task: "Scheduled task", + free_create: "Free create", }, }, + freeCreate: { + actions: { + create: "New Article", + }, + filters: { + publishStatus: "Publish Status", + keyword: "Search", + createdTime: "Created Time", + createdTimeStart: "Start", + createdTimeEnd: "End", + keywordPlaceholder: "Search by title", + }, + list: { + eyebrow: "Free Create", + count: "{count} articles", + empty: "No articles yet. Click \"New Article\" to start creating.", + edit: "Edit article", + deleteConfirm: "Are you sure you want to delete this article?", + deleteSuccess: "Article deleted", + deleteError: "Failed to delete article", + }, + createError: "Failed to create article", + }, templates: { eyebrow: "Article Creation", actions: { @@ -431,6 +455,30 @@ const enUS = { resetSize: "Reset size", delete: "Delete image", }, + aiOptimize: { + title: "AI Optimize", + subtitle: "Supports contiguous multi-block selections and generates a rewrite from your instruction", + originalLabel: "Selected text", + previewLabel: "Preview", + previewPlaceholder: "After you enter an instruction, the AI rewrite will stream here.", + generating: "Generating...", + streamingHint: "Building a stronger version of the selected text in real time...", + disclaimer: "AI output is for reference only. Review it before replacing the original text.", + promptLabel: "Instruction", + promptPlaceholder: "For example: make it more natural, classroom-friendly, and persuasive while keeping the facts unchanged.", + generate: "Generate", + regenerate: "Regenerate", + stop: "Stop", + replace: "Replace selection", + close: "Keep original", + messages: { + selectionRequired: "Select one block or multiple contiguous blocks of article text first.", + promptRequired: "Enter an instruction first.", + selectionChanged: "The original selection changed. Please select it again before replacing.", + empty: "There is no AI content ready to replace yet.", + failed: "AI optimization failed. Please try again later.", + }, + }, messages: { saved: "Article saved.", generating: "This article is still generating. Try editing it later.", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index f1b586a..f9174a4 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -53,7 +53,7 @@ const zhCN = { articleCreation: "文章创作", templates: "模版创作", custom: "自定义生成", - optimize: "文章优化", + freeCreate: "自由创作", media: "媒体管理", brandManagement: "品牌管理", brands: "品牌词库", @@ -106,9 +106,9 @@ const zhCN = { title: "自定义生成", description: "基于自定义 Prompt 规则灵活生成文章,支持即时与定时任务。", }, - optimize: { - title: "文章优化", - description: "当前仓库还没有匹配的后端接口,页面暂保持为设计占位。", + freeCreate: { + title: "自由创作", + description: "自由创建并编辑文章,无需模版或AI生成。", }, media: { title: "媒体管理", @@ -209,8 +209,32 @@ const zhCN = { custom_generation: "自定义生成", instant_task: "即时任务", schedule_task: "定时任务", + free_create: "自由创作", }, }, + freeCreate: { + actions: { + create: "新建文章", + }, + filters: { + publishStatus: "发布状态", + keyword: "搜索文章", + createdTime: "创建时间", + createdTimeStart: "开始时间", + createdTimeEnd: "结束时间", + keywordPlaceholder: "请输入标题内容搜索", + }, + list: { + eyebrow: "自由创作", + count: "共 {count} 篇文章", + empty: "还未创建文章,点击「新建文章」开始创作~", + edit: "编辑文章", + deleteConfirm: "确认删除这篇文章吗?", + deleteSuccess: "文章已删除", + deleteError: "删除文章失败", + }, + createError: "创建文章失败", + }, templates: { eyebrow: "Article Creation", actions: { @@ -438,6 +462,30 @@ const zhCN = { resetSize: "重置大小", delete: "删除图片", }, + aiOptimize: { + title: "AI 优化", + subtitle: "支持连续多段选中,基于你的优化要求生成候选改写", + originalLabel: "当前选中文字", + previewLabel: "优化预览", + previewPlaceholder: "输入你的优化要求后,AI 会在这里实时生成候选内容。", + generating: "正在生成中...", + streamingHint: "正在根据你的要求逐步生成更合适的表达...", + disclaimer: "AI 生成内容仅供参考,请确认后再替换原文。", + promptLabel: "优化要求", + promptPlaceholder: "例如:更自然一点,适合课堂导入,保留原有信息但更有感染力。", + generate: "开始生成", + regenerate: "重新生成", + stop: "停止", + replace: "替换原文", + close: "先不替换", + messages: { + selectionRequired: "请先选中一段或连续多段需要优化的正文内容。", + promptRequired: "请先输入你的优化要求。", + selectionChanged: "原选区已变化,请重新选中后再替换。", + empty: "当前还没有可替换的 AI 内容。", + failed: "AI 优化失败,请稍后重试。", + }, + }, messages: { saved: "文章已保存", generating: "文章还在生成中,请稍后再编辑。", diff --git a/apps/admin-web/src/layouts/AppShell.vue b/apps/admin-web/src/layouts/AppShell.vue index c020f33..06460c6 100644 --- a/apps/admin-web/src/layouts/AppShell.vue +++ b/apps/admin-web/src/layouts/AppShell.vue @@ -72,7 +72,7 @@ const navSections = computed(() => [ items: [ { key: "/articles/templates", label: t("nav.templates") }, { key: "/articles/custom", label: t("nav.custom") }, - { key: "/articles/optimize", label: t("nav.optimize") }, + { key: "/articles/free-create", label: t("nav.freeCreate") }, { key: "/media", label: t("nav.media"), icon: GlobalOutlined }, ], }, diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 4e67253..c25e40d 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -5,6 +5,7 @@ import type { ArticleImageUploadResponse, ArticleListParams, ArticleListResponse, + CreateArticleRequest, ArticleVersion, AuthTokens, Brand, @@ -210,6 +211,9 @@ export const articlesApi = { list(params: ArticleListParams) { return apiClient.get("/api/tenant/articles", { params }); }, + create(payload: CreateArticleRequest = {}) { + return apiClient.post("/api/tenant/articles", payload); + }, detail(id: number) { return apiClient.get(`/api/tenant/articles/${id}`); }, @@ -305,6 +309,24 @@ export interface ArticleGenerationStreamEvent { updated_at?: string; } +export interface ArticleSelectionOptimizeRequest { + title: string; + markdown_content: string; + selected_text: string; + instruction: string; +} + +export interface ArticleSelectionOptimizeStreamEvent { + type: string; + article_id: number; + status?: string; + delta?: string; + content?: string; + error?: string; + model?: string; + done?: boolean; +} + export function isGenerationStreamEnabled(): boolean { return generationStreamEnabled; } @@ -316,18 +338,65 @@ export async function subscribeArticleGeneration( onClose?: () => void; }, signal: AbortSignal, +): Promise { + return subscribeSSE( + `/api/tenant/articles/${articleId}/stream`, + { + method: "GET", + }, + handlers, + signal, + ); +} + +export async function streamArticleSelectionOptimize( + articleId: number, + payload: ArticleSelectionOptimizeRequest, + handlers: { + onEvent: (event: ArticleSelectionOptimizeStreamEvent) => void; + onClose?: () => void; + }, + signal: AbortSignal, +): Promise { + return subscribeSSE( + `/api/tenant/articles/${articleId}/selection-optimize/stream`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }, + handlers, + signal, + ); +} + +type SSEEventShape = { + type: string; +}; + +async function subscribeSSE( + path: string, + init: RequestInit, + handlers: { + onEvent: (event: TEvent) => void; + onClose?: () => void; + }, + signal: AbortSignal, ): Promise { const accessToken = getStoredAccessToken(); if (!accessToken) { throw new Error("missing access token"); } - const response = await fetch(`${baseURL}/api/tenant/articles/${articleId}/stream`, { - method: "GET", - headers: { - Accept: "text/event-stream", - Authorization: `Bearer ${accessToken}`, - }, + const requestHeaders = new Headers(init.headers); + requestHeaders.set("Accept", "text/event-stream"); + requestHeaders.set("Authorization", `Bearer ${accessToken}`); + + const response = await fetch(`${baseURL}${path}`, { + ...init, + headers: requestHeaders, signal, }); @@ -350,11 +419,11 @@ export async function subscribeArticleGeneration( } buffer += decoder.decode(value, { stream: true }); - buffer = consumeSSEBuffer(buffer, handlers.onEvent); + buffer = consumeSSEBuffer(buffer, handlers.onEvent); } buffer += decoder.decode(); - consumeSSEBuffer(buffer, handlers.onEvent); + consumeSSEBuffer(buffer, handlers.onEvent); } finally { reader.releaseLock(); handlers.onClose?.(); @@ -543,9 +612,9 @@ export function normalizeInputParams( }; } -function consumeSSEBuffer( +function consumeSSEBuffer( buffer: string, - onEvent: (event: ArticleGenerationStreamEvent) => void, + onEvent: (event: TEvent) => void, ): string { let next = buffer; @@ -558,7 +627,7 @@ function consumeSSEBuffer( const rawEvent = next.slice(0, delimiterIndex); next = next.slice(delimiterIndex + 2); - const parsed = parseSSEEvent(rawEvent); + const parsed = parseSSEEvent(rawEvent); if (parsed) { onEvent(parsed); } @@ -567,7 +636,7 @@ function consumeSSEBuffer( return next; } -function parseSSEEvent(raw: string): ArticleGenerationStreamEvent | null { +function parseSSEEvent(raw: string): TEvent | null { const lines = raw.split(/\r?\n/); let event = "message"; const dataLines: string[] = []; @@ -586,9 +655,13 @@ function parseSSEEvent(raw: string): ArticleGenerationStreamEvent | null { return null; } - const data = JSON.parse(dataLines.join("\n")) as unknown as ArticleGenerationStreamEvent; + const data = JSON.parse(dataLines.join("\n")) as Record | null; + if (!data || Array.isArray(data)) { + return null; + } + return { ...data, type: event, - }; + } as TEvent; } diff --git a/apps/admin-web/src/lib/display.ts b/apps/admin-web/src/lib/display.ts index 3f6fe98..f457d49 100644 --- a/apps/admin-web/src/lib/display.ts +++ b/apps/admin-web/src/lib/display.ts @@ -54,6 +54,9 @@ export function getSourceTypeLabel(sourceType?: string | null, generationMode?: if (sourceType === "template") { return i18n.global.t("status.sourceType.template"); } + if (sourceType === "free_create") { + return i18n.global.t("status.sourceType.free_create"); + } if (sourceType === "custom_generation") { if (generationMode === "schedule") { return i18n.global.t("status.sourceType.schedule_task"); diff --git a/apps/admin-web/src/main.ts b/apps/admin-web/src/main.ts index 50bda37..048915e 100644 --- a/apps/admin-web/src/main.ts +++ b/apps/admin-web/src/main.ts @@ -69,7 +69,7 @@ const app = createApp(App); const IconFont = createFromIconfontCN({ // 需要替换为你自己真实项目的 js 地址,才能显示出你的 '#icon-line-global_undo'。 - scriptUrl: "//at.alicdn.com/t/c/font_5154382_bf9lssq7ar.js", + scriptUrl: "//at.alicdn.com/t/c/font_5154382_922oh81rh1.js", }); app.component("IconFont", IconFont); diff --git a/apps/admin-web/src/router/index.ts b/apps/admin-web/src/router/index.ts index f75af6d..b857163 100644 --- a/apps/admin-web/src/router/index.ts +++ b/apps/admin-web/src/router/index.ts @@ -77,13 +77,13 @@ const router = createRouter({ }, }, { - path: "articles/optimize", - name: "articles-optimize", - component: () => import("@/views/FeatureStubView.vue"), + path: "articles/free-create", + name: "articles-free-create", + component: () => import("@/views/FreeCreateView.vue"), meta: { - titleKey: "route.optimize.title", - descriptionKey: "route.optimize.description", - navKey: "/articles/optimize", + titleKey: "route.freeCreate.title", + descriptionKey: "route.freeCreate.description", + navKey: "/articles/free-create", }, }, { diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index 6213c11..5b594bb 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -171,11 +171,30 @@ async function handlePublished(): Promise { ]); } +const isFreeCreateEmpty = computed( + () => + detail.value?.source_type === "free_create" && + title.value.trim() === "" && + markdown.value.trim() === "", +); + +const isFreeCreateInitiallyEmpty = computed( + () => + detail.value?.source_type === "free_create" && + initialTitle.value.trim() === "" && + initialMarkdown.value.trim() === "", +); + function handleBack(): void { if (saveMutation.isPending.value) { return; } + if (isFreeCreateEmpty.value) { + void cleanupEmptyFreeCreate(); + return; + } + if (hasChanges.value) { leaveModalOpen.value = true; return; @@ -184,6 +203,19 @@ function handleBack(): void { navigateBack(); } +async function cleanupEmptyFreeCreate(): Promise { + try { + await articlesApi.remove(articleId.value); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["articles"] }), + queryClient.invalidateQueries({ queryKey: ["workspace"] }), + ]); + } catch { + // ignore cleanup errors + } + navigateBack(); +} + function navigateBack(): void { if (window.history.length > 1) { void router.back(); @@ -199,6 +231,10 @@ function handleStay(): void { function handleDiscardLeave(): void { leaveModalOpen.value = false; + if (isFreeCreateInitiallyEmpty.value) { + void cleanupEmptyFreeCreate(); + return; + } navigateBack(); } @@ -293,6 +329,7 @@ function serializePlatformSelection(platformIds: string[]): string { +import { + DeleteOutlined, + EditOutlined, + SendOutlined, + PlusOutlined, + LoadingOutlined, +} from "@ant-design/icons-vue"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; +import { message } from "ant-design-vue"; +import type { ArticleListItem, ArticleListParams } from "@geo/shared-types"; +import type { TableColumnsType } from "ant-design-vue"; +import type { Dayjs } from "dayjs"; +import { computed, onBeforeUnmount, reactive, ref, watch } from "vue"; +import { useRouter } from "vue-router"; +import { useI18n } from "vue-i18n"; + +import PublishArticleModal from "@/components/PublishArticleModal.vue"; +import { articlesApi } from "@/lib/api"; +import { formatError } from "@/lib/errors"; +import { + formatDateTime, + getGenerateStatusMeta, + getPublishStatusMeta, + getSourceTypeLabel, +} from "@/lib/display"; + +const queryClient = useQueryClient(); +const router = useRouter(); +const { t } = useI18n(); + +const publishModalOpen = ref(false); +const selectedPublishArticleId = ref(null); +const page = ref(1); +const pageSize = ref(10); +const createdRange = ref<[Dayjs, Dayjs] | null>(null); + +const draftFilters = reactive<{ + publish_status?: string; + keyword?: string; + created_from?: string; + created_to?: string; +}>({ + keyword: "", +}); + +const appliedFilters = reactive<{ + publish_status?: string; + keyword?: string; + created_from?: string; + created_to?: string; +}>({ + keyword: "", +}); + +const articleParams = computed(() => { + const params: ArticleListParams = { + page: page.value, + page_size: pageSize.value, + source_type: "free_create", + }; + + if (appliedFilters.publish_status) { + params.publish_status = appliedFilters.publish_status; + } + if (appliedFilters.keyword?.trim()) { + params.keyword = appliedFilters.keyword.trim(); + } + if (appliedFilters.created_from) { + params.created_from = appliedFilters.created_from; + } + if (appliedFilters.created_to) { + params.created_to = appliedFilters.created_to; + } + + return params; +}); + +const articleListQuery = useQuery({ + queryKey: computed(() => ["articles", "list", articleParams.value]), + queryFn: () => articlesApi.list(articleParams.value), +}); + +const deleteMutation = useMutation({ + mutationFn: (articleId: number) => articlesApi.remove(articleId), + onSuccess: async () => { + message.success(t("freeCreate.list.deleteSuccess")); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["articles"] }), + queryClient.invalidateQueries({ queryKey: ["workspace"] }), + ]); + }, + onError: (error) => { + message.error(formatError(error) || t("freeCreate.list.deleteError")); + }, +}); + +const createMutation = useMutation({ + mutationFn: () => articlesApi.create({}), + onSuccess: (data) => { + void router.push({ name: "article-editor", params: { id: String(data.id) } }); + }, + onError: (error) => { + message.error(formatError(error) || t("freeCreate.createError")); + }, +}); + +const publishStatusOptions = computed(() => [ + { label: getPublishStatusMeta("unpublished").label, value: "unpublished" }, + { label: getPublishStatusMeta("publishing").label, value: "publishing" }, + { label: getPublishStatusMeta("success").label, value: "success" }, + { label: getPublishStatusMeta("partial_success").label, value: "partial_success" }, + { label: getPublishStatusMeta("failed").label, value: "failed" }, + { label: getPublishStatusMeta("pending_review").label, value: "pending_review" }, +]); + +const articleColumns = computed>(() => [ + { + title: t("common.title"), + dataIndex: "title", + key: "title", + }, + { + title: t("common.publishStatus"), + dataIndex: "publish_status", + key: "publish_status", + width: 128, + }, + { + title: t("common.wordCount"), + dataIndex: "word_count", + key: "word_count", + width: 100, + }, + { + title: t("common.actions"), + key: "actions", + width: 156, + align: "right", + }, +]); + +function applyFilters(): void { + page.value = 1; + appliedFilters.publish_status = draftFilters.publish_status; + appliedFilters.keyword = draftFilters.keyword?.trim() || ""; + appliedFilters.created_from = createdRange.value?.[0]?.toISOString(); + appliedFilters.created_to = createdRange.value?.[1]?.toISOString(); +} + +function resetFilters(): void { + draftFilters.publish_status = undefined; + draftFilters.keyword = ""; + createdRange.value = null; + applyFilters(); +} + +function openEditor(article: ArticleListItem): void { + void router.push({ name: "article-editor", params: { id: String(article.id) } }); +} + +function openPublish(article: ArticleListItem): void { + selectedPublishArticleId.value = article.id; + publishModalOpen.value = true; +} + +function canPublishArticle(status: string): boolean { + return status === "completed"; +} + +function handleTableChange(nextPage: number, nextPageSize: number): void { + page.value = nextPage; + pageSize.value = nextPageSize; +} + +async function handleDelete(articleId: number): Promise { + await deleteMutation.mutateAsync(articleId); +} + +function handleCreate(): void { + createMutation.mutate(); +} + + + + + diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 3d80b47..3864f5f 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -267,6 +267,10 @@ export interface ArticleDetail { created_at: string; } +export interface CreateArticleRequest { + title?: string; +} + export interface UpdateArticleRequest { title: string; markdown_content: string; diff --git a/server/configs/config.yaml b/server/configs/config.yaml index b14df68..4528238 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -48,7 +48,7 @@ llm: provider: ark base_url: https://ark.cn-beijing.volces.com/api/v3 api_key: "7fb6c66b-129c-4935-9617-709236c4205a" - model: doubao-seed-2-0-lite-260215 + model: doubao-seed-2-0-mini-260215 #doubao-seed-2-0-lite-260215 knowledge_url_model: doubao-seed-2-0-mini-260215 timeout: 2m max_output_tokens: 16000 diff --git a/server/internal/tenant/app/article_selection_optimize_service.go b/server/internal/tenant/app/article_selection_optimize_service.go new file mode 100644 index 0000000..f68853f --- /dev/null +++ b/server/internal/tenant/app/article_selection_optimize_service.go @@ -0,0 +1,228 @@ +package app + +import ( + "context" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/geo-platform/tenant-api/internal/shared/auth" + "github.com/geo-platform/tenant-api/internal/shared/llm" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +const ( + maxOptimizeInstructionChars = 800 + maxOptimizeMarkdownContextLen = 6000 + minOptimizeSelectedTextRunes = 2 + optimizeSelectionTimeout = 90 * time.Second +) + +type ArticleSelectionOptimizeRequest struct { + Title string `json:"title"` + MarkdownContent string `json:"markdown_content"` + SelectedText string `json:"selected_text"` + Instruction string `json:"instruction"` +} + +type ArticleSelectionOptimizeResult struct { + Content string `json:"content"` + Model string `json:"model"` +} + +type ArticleSelectionOptimizeService struct { + pool *pgxpool.Pool + llm llm.Client + defaultMaxOutTokens int64 +} + +func NewArticleSelectionOptimizeService( + pool *pgxpool.Pool, + llmClient llm.Client, + defaultMaxOutTokens int64, +) *ArticleSelectionOptimizeService { + return &ArticleSelectionOptimizeService{ + pool: pool, + llm: llmClient, + defaultMaxOutTokens: defaultMaxOutTokens, + } +} + +func (s *ArticleSelectionOptimizeService) ValidateRequest( + ctx context.Context, + articleID int64, + req ArticleSelectionOptimizeRequest, +) error { + if err := s.validateLLM(); err != nil { + return err + } + if err := validateOptimizeSelectionPayload(req); err != nil { + return err + } + + actor := auth.MustActor(ctx) + return s.ensureArticleEditable(ctx, articleID, actor.TenantID) +} + +func (s *ArticleSelectionOptimizeService) OptimizeSelection( + ctx context.Context, + req ArticleSelectionOptimizeRequest, + onDelta func(string), +) (*ArticleSelectionOptimizeResult, error) { + result, err := s.llm.Generate( + ctx, + llm.GenerateRequest{ + Prompt: buildOptimizeSelectionPrompt(req), + Timeout: optimizeSelectionTimeout, + MaxOutputTokens: s.resolveMaxOutputTokens(req.SelectedText), + }, + onDelta, + ) + if err != nil { + return nil, response.ErrInternal(50019, "article_selection_optimize_failed", err.Error()) + } + + content := strings.TrimSpace(result.Content) + if content == "" { + return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "optimized content is empty") + } + + return &ArticleSelectionOptimizeResult{ + Content: content, + Model: result.Model, + }, nil +} + +func (s *ArticleSelectionOptimizeService) validateLLM() error { + if err := s.llm.Validate(); err != nil { + return response.ErrServiceUnavailable(50304, "llm_unavailable", "llm provider is not configured") + } + return nil +} + +func (s *ArticleSelectionOptimizeService) ensureArticleEditable( + ctx context.Context, + articleID int64, + tenantID int64, +) error { + var generateStatus string + err := s.pool.QueryRow( + ctx, + `SELECT generate_status + FROM articles + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, + articleID, + tenantID, + ).Scan(&generateStatus) + if err != nil { + if err == pgx.ErrNoRows { + return response.ErrNotFound(40411, "article_not_found", "article not found") + } + return response.ErrInternal(50020, "article_lookup_failed", "failed to query article state") + } + + if generateStatus != "completed" { + return response.ErrConflict(40913, "article_not_optimizable", "only completed articles can be optimized") + } + + return nil +} + +func (s *ArticleSelectionOptimizeService) resolveMaxOutputTokens(selectedText string) int64 { + limit := s.defaultMaxOutTokens + if limit <= 0 { + limit = 1200 + } + + estimated := int64(utf8.RuneCountInString(strings.TrimSpace(selectedText))*4 + 240) + if estimated < 256 { + estimated = 256 + } + if estimated > limit { + return limit + } + return estimated +} + +func validateOptimizeSelectionPayload(req ArticleSelectionOptimizeRequest) error { + selectedText := strings.TrimSpace(req.SelectedText) + if utf8.RuneCountInString(selectedText) < minOptimizeSelectedTextRunes { + return response.ErrBadRequest(40019, "selected_text_required", "selected_text is required") + } + + instruction := strings.TrimSpace(req.Instruction) + if instruction == "" { + return response.ErrBadRequest(40020, "optimize_instruction_required", "instruction is required") + } + + if len([]rune(instruction)) > maxOptimizeInstructionChars { + return response.ErrBadRequest( + 40021, + "optimize_instruction_too_long", + fmt.Sprintf("instruction cannot exceed %d characters", maxOptimizeInstructionChars), + ) + } + + return nil +} + +func buildOptimizeSelectionPrompt(req ArticleSelectionOptimizeRequest) string { + title := sanitizeOptimizePromptSection(req.Title, 300) + markdownContent := sanitizeOptimizePromptSection(req.MarkdownContent, maxOptimizeMarkdownContextLen) + selectedText := sanitizeOptimizePromptSection(req.SelectedText, 2000) + instruction := sanitizeOptimizePromptSection(req.Instruction, maxOptimizeInstructionChars) + + var builder strings.Builder + builder.WriteString("你是一名资深中文内容编辑。请根据用户要求,优化一段文章选中文本。\n") + builder.WriteString("请严格遵守以下规则:\n") + builder.WriteString("1. 只输出优化后的正文内容,不要输出解释、标题、引号、前后缀说明或 Markdown 代码块。\n") + builder.WriteString("2. 保持原文事实、结论、人物、时间、数字和专有名词准确,不得编造信息。\n") + builder.WriteString("3. 优先遵循用户给出的优化要求,同时让表达与整篇文章风格保持自然一致。\n") + builder.WriteString("4. 如果原文是一段话,默认输出一段话;除非用户明确要求改成其他结构。\n") + builder.WriteString("5. 不要复述“以下是优化结果”等说明性文字。\n") + + if title != "" { + builder.WriteString("\n文章标题:\n") + builder.WriteString(title) + builder.WriteString("\n") + } + + if markdownContent != "" { + builder.WriteString("\n文章草稿(用于参考语气和上下文,可能不是最终定稿):\n") + builder.WriteString(markdownContent) + builder.WriteString("\n") + } + + builder.WriteString("\n待优化原文:\n") + builder.WriteString(selectedText) + builder.WriteString("\n") + + builder.WriteString("\n用户的优化要求:\n") + builder.WriteString(instruction) + builder.WriteString("\n") + + builder.WriteString("\n现在请直接输出优化后的文本。") + return builder.String() +} + +func sanitizeOptimizePromptSection(value string, maxRunes int) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "" + } + + normalized = strings.ReplaceAll(normalized, "\u0000", "") + normalized = strings.ReplaceAll(normalized, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + + runes := []rune(normalized) + if maxRunes > 0 && len(runes) > maxRunes { + return strings.TrimSpace(string(runes[:maxRunes])) + "\n...[内容已截断]" + } + + return normalized +} diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go index 48c8598..822c494 100644 --- a/server/internal/tenant/app/article_service.go +++ b/server/internal/tenant/app/article_service.go @@ -314,6 +314,54 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe return &d, nil } +type CreateArticleRequest struct { + Title string `json:"title"` +} + +func (s *ArticleService) Create(ctx context.Context, req CreateArticleRequest) (*ArticleDetailResponse, error) { + actor := auth.MustActor(ctx) + + title := strings.TrimSpace(req.Title) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, response.ErrInternal(50014, "create_failed", "failed to begin article creation transaction") + } + defer tx.Rollback(ctx) + + var articleID int64 + if err := tx.QueryRow(ctx, ` + INSERT INTO articles (tenant_id, source_type, template_id, generate_status, publish_status) + VALUES ($1, 'free_create', NULL, 'completed', 'unpublished') + RETURNING id + `, actor.TenantID).Scan(&articleID); err != nil { + return nil, response.ErrInternal(50014, "create_failed", "failed to create article") + } + + sourceLabel := "自由创作" + var versionID int64 + if err := tx.QueryRow(ctx, ` + INSERT INTO article_versions (article_id, version_no, title, html_content, markdown_content, word_count, source_label) + VALUES ($1, 1, $2, NULL, '', 0, $3) + RETURNING id + `, articleID, title, sourceLabel).Scan(&versionID); err != nil { + return nil, response.ErrInternal(50014, "create_failed", "failed to create article version") + } + + if _, err := tx.Exec(ctx, ` + UPDATE articles SET current_version_id = $1, updated_at = NOW() + WHERE id = $2 AND tenant_id = $3 + `, versionID, articleID, actor.TenantID); err != nil { + return nil, response.ErrInternal(50014, "create_failed", "failed to link article version") + } + + if err := tx.Commit(ctx); err != nil { + return nil, response.ErrInternal(50014, "create_failed", "failed to commit article creation") + } + + return s.Detail(ctx, articleID) +} + type UpdateArticleRequest struct { Title string `json:"title"` MarkdownContent string `json:"markdown_content"` diff --git a/server/internal/tenant/transport/article_handler.go b/server/internal/tenant/transport/article_handler.go index 1a1f27f..c8d06d2 100644 --- a/server/internal/tenant/transport/article_handler.go +++ b/server/internal/tenant/transport/article_handler.go @@ -1,10 +1,13 @@ package transport import ( + "context" "encoding/json" + "errors" "io" "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -17,6 +20,7 @@ import ( type ArticleHandler struct { svc *app.ArticleService + selectionOptimize *app.ArticleSelectionOptimizeService promptGenerateSvc *app.PromptRuleGenerationService streams *stream.GenerationHub streamEnabled bool @@ -39,6 +43,11 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler { return &ArticleHandler{ svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage), + selectionOptimize: app.NewArticleSelectionOptimizeService( + a.DB, + a.LLM, + a.Config.LLM.MaxOutputTokens, + ), promptGenerateSvc: app.NewPromptRuleGenerationService( a.DB, a.LLM, @@ -124,6 +133,22 @@ func (h *ArticleHandler) GenerateFromRule(c *gin.Context) { response.SuccessWithStatus(c, http.StatusCreated, data) } +func (h *ArticleHandler) Create(c *gin.Context) { + var req app.CreateArticleRequest + if err := c.ShouldBindJSON(&req); err != nil { + // Allow empty body + req = app.CreateArticleRequest{} + } + + data, err := h.svc.Create(c.Request.Context(), req) + if err != nil { + response.Error(c, err) + return + } + + response.SuccessWithStatus(c, http.StatusCreated, data) +} + func (h *ArticleHandler) Detail(c *gin.Context) { id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { @@ -210,6 +235,111 @@ func (h *ArticleHandler) UploadImage(c *gin.Context) { response.SuccessWithStatus(c, http.StatusCreated, data) } +type articleSelectionOptimizeStreamEvent struct { + ArticleID int64 `json:"article_id"` + Status string `json:"status,omitempty"` + Delta string `json:"delta,omitempty"` + Content string `json:"content,omitempty"` + Error string `json:"error,omitempty"` + Model string `json:"model,omitempty"` + Done bool `json:"done"` +} + +func (h *ArticleHandler) OptimizeSelectionStream(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number")) + return + } + + var req app.ArticleSelectionOptimizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, response.ErrBadRequest(40013, "invalid_payload", err.Error())) + return + } + + if err := h.selectionOptimize.ValidateRequest(c.Request.Context(), id, req); err != nil { + response.Error(c, err) + return + } + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + response.Error(c, response.ErrInternal(50011, "stream_unsupported", "response writer does not support streaming")) + return + } + + if err := writeSSE(c, "start", articleSelectionOptimizeStreamEvent{ + ArticleID: id, + Status: "generating", + Done: false, + }); err != nil { + return + } + flusher.Flush() + + streamCtx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + var streamed strings.Builder + var streamWriteErr error + + result, err := h.selectionOptimize.OptimizeSelection(streamCtx, req, func(delta string) { + if delta == "" || streamWriteErr != nil { + return + } + + streamed.WriteString(delta) + if writeErr := writeSSE(c, "delta", articleSelectionOptimizeStreamEvent{ + ArticleID: id, + Status: "generating", + Delta: delta, + Content: streamed.String(), + Done: false, + }); writeErr != nil { + streamWriteErr = writeErr + cancel() + return + } + flusher.Flush() + }) + if streamWriteErr != nil { + return + } + if err != nil { + if errors.Is(streamCtx.Err(), context.Canceled) { + return + } + + appErr := response.Normalize(err) + _ = writeSSE(c, "error", articleSelectionOptimizeStreamEvent{ + ArticleID: id, + Status: "failed", + Content: strings.TrimSpace(streamed.String()), + Error: resolveOptimizeStreamErrorMessage(appErr), + Done: true, + }) + flusher.Flush() + return + } + + if err := writeSSE(c, "completed", articleSelectionOptimizeStreamEvent{ + ArticleID: id, + Status: "completed", + Content: result.Content, + Model: result.Model, + Done: true, + }); err != nil { + return + } + flusher.Flush() +} + func (h *ArticleHandler) Stream(c *gin.Context) { if !h.streamEnabled { response.Error(c, response.ErrNotFound(40412, "generation_stream_disabled", "generation stream is disabled")) @@ -324,3 +454,16 @@ func writeSSE(c *gin.Context, event string, payload interface{}) error { } return nil } + +func resolveOptimizeStreamErrorMessage(appErr *response.AppError) string { + if appErr == nil { + return "optimize selection failed" + } + if detail := strings.TrimSpace(appErr.Detail); detail != "" { + return detail + } + if message := strings.TrimSpace(appErr.Message); message != "" { + return message + } + return "optimize selection failed" +} diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go index 1bd3314..21816b8 100644 --- a/server/internal/tenant/transport/router.go +++ b/server/internal/tenant/transport/router.go @@ -50,8 +50,10 @@ func RegisterRoutes(app *bootstrap.App) { articles := protected.Group("/tenant/articles") artHandler := NewArticleHandler(app) articles.GET("", artHandler.List) + articles.POST("", artHandler.Create) articles.POST("/generate-from-rule", artHandler.GenerateFromRule) articles.POST("/:id/images", artHandler.UploadImage) + articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream) articles.GET("/:id", artHandler.Detail) articles.PUT("/:id", artHandler.Update) articles.GET("/:id/stream", artHandler.Stream)