From fcd85d965153b2ebe71d2dca5d4f6459ad8428a9 Mon Sep 17 00:00:00 2001 From: Chao Liu Date: Sun, 17 Aug 2025 22:36:36 +0800 Subject: [PATCH] feat: implement tag recommendation settings and UI integration - Add TagRecommendationSection settings component - Integrate tag recommendation button into memo editor - Add localization for tag recommendation features - Update AI settings to include tag recommendation configuration Signed-off-by: Chao Liu --- web/src/components/MemoEditor/index.tsx | 87 ++++++++ web/src/components/Settings/AISettings.tsx | 23 ++ .../Settings/TagRecommendationSection.tsx | 196 ++++++++++++++++++ web/src/locales/en.json | 12 +- web/src/locales/zh-Hans.json | 12 +- 5 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 web/src/components/Settings/TagRecommendationSection.tsx diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 1e9a1de70..06ecbd1f5 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -25,11 +25,13 @@ import DateTimeInput from "../DateTimeInput"; import AddMemoRelationPopover from "./ActionButton/AddMemoRelationPopover"; import LocationSelector from "./ActionButton/LocationSelector"; import MarkdownMenu from "./ActionButton/MarkdownMenu"; +import TagRecommendButton from "./ActionButton/TagRecommendButton"; import TagSelector from "./ActionButton/TagSelector"; import UploadAttachmentButton from "./ActionButton/UploadAttachmentButton"; import AttachmentListView from "./AttachmentListView"; import Editor, { EditorRefActions } from "./Editor"; import RelationListView from "./RelationListView"; +import TagRecommendationPanel from "./TagRecommendationPanel"; import { handleEditorKeydownWithMarkdownShortcuts, hyperlinkHighlightedText } from "./handlers"; import { MemoEditorContext } from "./types"; @@ -46,6 +48,11 @@ export interface Props { onCancel?: () => void; } +interface TagSuggestion { + tag: string; + reason: string; +} + interface State { memoVisibility: Visibility; attachmentList: Attachment[]; @@ -55,6 +62,9 @@ interface State { isRequesting: boolean; isComposing: boolean; isDraggingFile: boolean; + isRecommendationVisible: boolean; + recommendedTags: TagSuggestion[]; + isLoadingRecommendation: boolean; } const MemoEditor = observer((props: Props) => { @@ -71,6 +81,9 @@ const MemoEditor = observer((props: Props) => { isRequesting: false, isComposing: false, isDraggingFile: false, + isRecommendationVisible: false, + recommendedTags: [], + isLoadingRecommendation: false, }); const [createTime, setCreateTime] = useState(); const [updateTime, setUpdateTime] = useState(); @@ -432,6 +445,9 @@ const MemoEditor = observer((props: Props) => { relationList: [], location: undefined, isDraggingFile: false, + isRecommendationVisible: false, + recommendedTags: [], + isLoadingRecommendation: false, }; }); }; @@ -448,6 +464,64 @@ const MemoEditor = observer((props: Props) => { editorRef.current?.focus(); }; + const handleTagRecommend = (tags: TagSuggestion[]) => { + setState((prevState) => ({ + ...prevState, + isRecommendationVisible: true, + recommendedTags: tags, + })); + }; + + const handleRecommendedTagClick = (tag: string) => { + if (!editorRef.current) return; + + const content = editorRef.current.getContent(); + const lastChar = content.slice(-1); + + // Move cursor to end of content + editorRef.current.setCursorPosition(content.length); + + if (content !== "" && lastChar !== "\n") { + editorRef.current.insertText("\n"); + } + + editorRef.current.insertText("\n"); + editorRef.current.insertText(`#${tag} `); + + // Remove the tag from recommendations after adding it + setState((prevState) => ({ + ...prevState, + recommendedTags: prevState.recommendedTags.filter((t) => t.tag !== tag), + })); + }; + + const handleAddAllRecommendedTags = () => { + if (!editorRef.current || state.recommendedTags.length === 0) return; + + const content = editorRef.current.getContent(); + const lastChar = content.slice(-1); + + // Move cursor to end of content + editorRef.current.setCursorPosition(content.length); + + if (content !== "" && lastChar !== "\n") { + editorRef.current.insertText("\n"); + } + + editorRef.current.insertText("\n"); + + // Add all tags + const tagsText = state.recommendedTags.map((tagSuggestion) => `#${tagSuggestion.tag}`).join(" ") + " "; + editorRef.current.insertText(tagsText); + + // Clear recommendations + setState((prevState) => ({ + ...prevState, + isRecommendationVisible: false, + recommendedTags: [], + })); + }; + const editorConfig = useMemo( () => ({ className: "", @@ -499,6 +573,14 @@ const MemoEditor = observer((props: Props) => { + {state.isRecommendationVisible && ( + setState({ ...state, isRecommendationVisible: false })} + /> + )}
e.stopPropagation()}>
@@ -516,6 +598,11 @@ const MemoEditor = observer((props: Props) => { />
+ {props.onCancel && (
+ + {/* AI Features Section */} + {aiSetting.enableAi && ( + <> + +
+
+ {t("setting.ai-features")} +
+ + +
+ + )}
); }); diff --git a/web/src/components/Settings/TagRecommendationSection.tsx b/web/src/components/Settings/TagRecommendationSection.tsx new file mode 100644 index 000000000..98de5d098 --- /dev/null +++ b/web/src/components/Settings/TagRecommendationSection.tsx @@ -0,0 +1,196 @@ +import { isEqual } from "lodash-es"; +import { observer } from "mobx-react-lite"; +import { useState, useEffect } from "react"; +import { toast } from "react-hot-toast"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { workspaceServiceClient } from "@/grpcweb"; +import { workspaceStore } from "@/store"; +import { workspaceSettingNamePrefix } from "@/store/common"; +import { + WorkspaceSetting_AiSetting, + WorkspaceSetting_Key, + WorkspaceSetting_TagRecommendationConfig, +} from "@/types/proto/api/v1/workspace_service"; +import { useTranslate } from "@/utils/i18n"; + +interface Props { + aiSetting: WorkspaceSetting_AiSetting; + onSettingChange: (newSetting: WorkspaceSetting_AiSetting) => void; + disabled?: boolean; +} + +const TagRecommendationSection = observer(({ aiSetting, onSettingChange, disabled = false }: Props) => { + const t = useTranslate(); + const [originalTagConfig, setOriginalTagConfig] = useState( + aiSetting.tagRecommendation || + WorkspaceSetting_TagRecommendationConfig.fromPartial({ + enabled: false, + systemPrompt: "", + requestsPerMinute: 10, + }), + ); + const [tagConfig, setTagConfig] = useState(originalTagConfig); + const [defaultPrompt, setDefaultPrompt] = useState(""); + + // Sync local state when aiSetting changes + useEffect(() => { + const newTagConfig = + aiSetting.tagRecommendation || + WorkspaceSetting_TagRecommendationConfig.fromPartial({ + enabled: false, + systemPrompt: "", + requestsPerMinute: 10, + }); + + setOriginalTagConfig(newTagConfig); + setTagConfig(newTagConfig); + }, [aiSetting]); + + // Fetch default system prompt on component mount + useEffect(() => { + const fetchDefaultPrompt = async () => { + try { + const response = await workspaceServiceClient.getDefaultTagRecommendationPrompt({}); + setDefaultPrompt(response.systemPrompt); + } catch (error) { + console.error("Failed to fetch default system prompt:", error); + } + }; + fetchDefaultPrompt(); + }, []); + + const updateTagRecommendation = async (enabled: boolean) => { + const newTagConfig = WorkspaceSetting_TagRecommendationConfig.fromPartial({ + ...tagConfig, + enabled, + }); + + const newAiSetting = WorkspaceSetting_AiSetting.fromPartial({ + ...aiSetting, + tagRecommendation: newTagConfig, + }); + + try { + await workspaceStore.updateWorkspaceSetting({ + name: `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.AI}`, + aiSetting: newAiSetting, + }); + + setOriginalTagConfig(newTagConfig); + setTagConfig(newTagConfig); + onSettingChange(newAiSetting); + toast.success(t("message.update-succeed")); + } catch (error: any) { + console.error(error); + toast.error(error.response?.data?.message || error.message || t("message.update-failed")); + } + }; + + const updateTagConfig = (partial: Partial) => { + setTagConfig(WorkspaceSetting_TagRecommendationConfig.fromPartial({ ...tagConfig, ...partial })); + }; + + const saveTagConfig = async () => { + const newAiSetting = WorkspaceSetting_AiSetting.fromPartial({ + ...aiSetting, + tagRecommendation: tagConfig, + }); + + try { + await workspaceStore.updateWorkspaceSetting({ + name: `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.AI}`, + aiSetting: newAiSetting, + }); + setOriginalTagConfig(tagConfig); + onSettingChange(newAiSetting); + toast.success(t("message.update-succeed")); + } catch (error: any) { + console.error(error); + toast.error(error.response?.data?.message || error.message || t("message.update-failed")); + } + }; + + const resetTagConfig = () => setTagConfig(originalTagConfig); + const hasChanged = !isEqual(originalTagConfig, tagConfig); + + return ( +
+
+
+ {t("setting.tag-recommendation.title")} + + {tagConfig.enabled ? t("common.enabled") : t("common.disabled")} + +
+
+ +

{t("setting.tag-recommendation.description")}

+ +
+ {/* Enable Tag Recommendation Toggle */} +
+
+ + {t("setting.tag-recommendation.enable-description")} +
+ +
+ + {/* Tag Recommendation Configuration Fields */} + {tagConfig.enabled && !disabled && ( + <> +
+ +