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:
Chao Liu 2025-08-17 22:36:36 +08:00 committed by ChaoLiu
parent d706c32b35
commit fcd85d9651
5 changed files with 328 additions and 2 deletions

View file

@ -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")}

View file

@ -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>
);
});

View 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;

View file

@ -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",

View file

@ -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": "有代码",