mirror of
https://github.com/usememos/memos.git
synced 2025-12-26 02:31:57 +08:00
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 <chaoliu719@gmail.com>
This commit is contained in:
parent
d706c32b35
commit
fcd85d9651
5 changed files with 328 additions and 2 deletions
|
|
@ -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<Date | undefined>();
|
||||
const [updateTime, setUpdateTime] = useState<Date | undefined>();
|
||||
|
|
@ -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) => {
|
|||
<Editor ref={editorRef} {...editorConfig} />
|
||||
<AttachmentListView attachmentList={state.attachmentList} setAttachmentList={handleSetAttachmentList} />
|
||||
<RelationListView relationList={referenceRelations} setRelationList={handleSetRelationList} />
|
||||
{state.isRecommendationVisible && (
|
||||
<TagRecommendationPanel
|
||||
tags={state.recommendedTags}
|
||||
onTagClick={handleRecommendedTagClick}
|
||||
onAddAll={handleAddAllRecommendedTags}
|
||||
onClose={() => setState({ ...state, isRecommendationVisible: false })}
|
||||
/>
|
||||
)}
|
||||
<div className="relative w-full flex flex-row justify-between items-center py-1 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-row justify-start items-center opacity-60 shrink-1">
|
||||
<TagSelector editorRef={editorRef} />
|
||||
|
|
@ -516,6 +598,11 @@ const MemoEditor = observer((props: Props) => {
|
|||
/>
|
||||
</div>
|
||||
<div className="shrink-0 -mr-1 flex flex-row justify-end items-center gap-1">
|
||||
<TagRecommendButton
|
||||
editorRef={editorRef}
|
||||
contentLength={hasContent ? editorRef.current?.getContent().length || 0 : 0}
|
||||
onRecommend={handleTagRecommend}
|
||||
/>
|
||||
{props.onCancel && (
|
||||
<Button variant="ghost" className="opacity-60" disabled={state.isRequesting} onClick={handleCancelBtnClick}>
|
||||
{t("common.cancel")}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { workspaceStore } from "@/store";
|
|||
import { workspaceSettingNamePrefix } from "@/store/common";
|
||||
import { WorkspaceSetting_AiSetting, WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import TagRecommendationSection from "./TagRecommendationSection";
|
||||
|
||||
const AISettings = observer(() => {
|
||||
const t = useTranslate();
|
||||
|
|
@ -79,6 +80,10 @@ const AISettings = observer(() => {
|
|||
},
|
||||
);
|
||||
|
||||
const handleTagRecommendationChange = (newSetting: WorkspaceSetting_AiSetting) => {
|
||||
setOriginalSetting(newSetting);
|
||||
setAiSetting(newSetting);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6 pt-2 pb-4">
|
||||
|
|
@ -193,6 +198,24 @@ const AISettings = observer(() => {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Features Section */}
|
||||
{aiSetting.enableAi && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-400">{t("setting.ai-features")}</span>
|
||||
</div>
|
||||
|
||||
<TagRecommendationSection
|
||||
aiSetting={workspaceStore.state.aiSetting}
|
||||
onSettingChange={handleTagRecommendationChange}
|
||||
disabled={!aiSetting.enableAi}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
196
web/src/components/Settings/TagRecommendationSection.tsx
Normal file
196
web/src/components/Settings/TagRecommendationSection.tsx
Normal file
|
|
@ -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<WorkspaceSetting_TagRecommendationConfig>(
|
||||
aiSetting.tagRecommendation ||
|
||||
WorkspaceSetting_TagRecommendationConfig.fromPartial({
|
||||
enabled: false,
|
||||
systemPrompt: "",
|
||||
requestsPerMinute: 10,
|
||||
}),
|
||||
);
|
||||
const [tagConfig, setTagConfig] = useState<WorkspaceSetting_TagRecommendationConfig>(originalTagConfig);
|
||||
const [defaultPrompt, setDefaultPrompt] = useState<string>("");
|
||||
|
||||
// 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<WorkspaceSetting_TagRecommendationConfig>) => {
|
||||
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 (
|
||||
<div className="w-full flex flex-col gap-4 p-4 border rounded-md bg-muted/50">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-gray-600">{t("setting.tag-recommendation.title")}</span>
|
||||
<Badge variant={tagConfig.enabled ? "default" : "secondary"}>
|
||||
{tagConfig.enabled ? t("common.enabled") : t("common.disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">{t("setting.tag-recommendation.description")}</p>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{/* Enable Tag Recommendation Toggle */}
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="enable-tag-recommendation">{t("setting.tag-recommendation.enable")}</Label>
|
||||
<span className="text-sm text-gray-500">{t("setting.tag-recommendation.enable-description")}</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-tag-recommendation"
|
||||
checked={tagConfig.enabled}
|
||||
onCheckedChange={updateTagRecommendation}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tag Recommendation Configuration Fields */}
|
||||
{tagConfig.enabled && !disabled && (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="system-prompt">{t("setting.tag-recommendation.system-prompt")}</Label>
|
||||
<Textarea
|
||||
id="system-prompt"
|
||||
placeholder={defaultPrompt || t("setting.tag-recommendation.system-prompt-placeholder")}
|
||||
value={tagConfig.systemPrompt}
|
||||
onChange={(e) => updateTagConfig({ systemPrompt: e.target.value })}
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{t("setting.tag-recommendation.system-prompt-description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="rate-limit">{t("setting.tag-recommendation.rate-limit")}</Label>
|
||||
<Input
|
||||
id="rate-limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="10"
|
||||
value={tagConfig.requestsPerMinute}
|
||||
onChange={(e) => updateTagConfig({ requestsPerMinute: parseInt(e.target.value) || 10 })}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{t("setting.tag-recommendation.rate-limit-description")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{tagConfig.enabled && !disabled && (
|
||||
<div className="w-full flex flex-row justify-end items-center gap-2 mt-4">
|
||||
<Button variant="outline" onClick={resetTagConfig} disabled={!hasChanged}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={saveTagConfig} disabled={!hasChanged}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TagRecommendationSection;
|
||||
|
|
@ -117,7 +117,17 @@
|
|||
"add-your-comment-here": "Add your comment here...",
|
||||
"any-thoughts": "Any thoughts...",
|
||||
"save": "Save",
|
||||
"no-changes-detected": "No changes detected"
|
||||
"no-changes-detected": "No changes detected",
|
||||
"tag-recommend": {
|
||||
"tooltip": "Recommend tags",
|
||||
"too-short": "Content too short (min 15 characters)",
|
||||
"loading": "Loading recommendations...",
|
||||
"suggested-tags": "Suggested tags",
|
||||
"add-all": "Add all",
|
||||
"no-suggestions": "No tag suggestions found",
|
||||
"timeout": "Tag recommendation timed out. Please try again.",
|
||||
"error": "Failed to get tag recommendations"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,17 @@
|
|||
"add-your-comment-here": "请输入您的评论...",
|
||||
"any-thoughts": "此刻的想法...",
|
||||
"save": "保存",
|
||||
"no-changes-detected": "未检测到更改"
|
||||
"no-changes-detected": "未检测到更改",
|
||||
"tag-recommend": {
|
||||
"tooltip": "推荐标签",
|
||||
"too-short": "内容太短(至少15个字符)",
|
||||
"loading": "正在加载推荐...",
|
||||
"suggested-tags": "推荐标签",
|
||||
"add-all": "添加全部",
|
||||
"no-suggestions": "未找到标签推荐",
|
||||
"timeout": "标签推荐超时,请重试",
|
||||
"error": "获取标签推荐失败"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"has-code": "有代码",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue