mirror of https://github.com/usememos/memos.git
feat: add AI settings configuration
- Add AI settings protobuf definitions and workspace service - Implement AI settings management in backend store - Add AI settings UI component with localization support - Integrate AI settings into workspace and settings page Signed-off-by: Chao Liu <chaoliu719@gmail.com>
This commit is contained in:
parent
f300b9499e
commit
1a58d1c633
|
|
@ -68,6 +68,7 @@ message WorkspaceSetting {
|
|||
GeneralSetting general_setting = 2;
|
||||
StorageSetting storage_setting = 3;
|
||||
MemoRelatedSetting memo_related_setting = 4;
|
||||
AiSetting ai_setting = 5;
|
||||
}
|
||||
|
||||
// Enumeration of workspace setting keys.
|
||||
|
|
@ -79,6 +80,8 @@ message WorkspaceSetting {
|
|||
STORAGE = 2;
|
||||
// MEMO_RELATED is the key for memo related settings.
|
||||
MEMO_RELATED = 3;
|
||||
// AI is the key for AI settings.
|
||||
AI = 4;
|
||||
}
|
||||
|
||||
// General workspace settings configuration.
|
||||
|
|
@ -170,6 +173,20 @@ message WorkspaceSetting {
|
|||
// nsfw_tags is the list of tags that mark content as NSFW for blurring.
|
||||
repeated string nsfw_tags = 10;
|
||||
}
|
||||
|
||||
// AI configuration settings for workspace AI features.
|
||||
message AiSetting {
|
||||
// enable_ai enables AI features.
|
||||
bool enable_ai = 1;
|
||||
// base_url is the base URL for AI API.
|
||||
string base_url = 2;
|
||||
// api_key is the API key for AI service.
|
||||
string api_key = 3;
|
||||
// model is the AI model to use.
|
||||
string model = 4;
|
||||
// timeout_seconds is the timeout for AI requests in seconds.
|
||||
int32 timeout_seconds = 5;
|
||||
}
|
||||
}
|
||||
|
||||
// Request message for GetWorkspaceSetting method.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ enum WorkspaceSettingKey {
|
|||
STORAGE = 3;
|
||||
// MEMO_RELATED is the key for memo related settings.
|
||||
MEMO_RELATED = 4;
|
||||
// AI is the key for AI settings.
|
||||
AI = 5;
|
||||
}
|
||||
|
||||
message WorkspaceSetting {
|
||||
|
|
@ -23,6 +25,7 @@ message WorkspaceSetting {
|
|||
WorkspaceGeneralSetting general_setting = 3;
|
||||
WorkspaceStorageSetting storage_setting = 4;
|
||||
WorkspaceMemoRelatedSetting memo_related_setting = 5;
|
||||
WorkspaceAISetting ai_setting = 6;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -115,3 +118,16 @@ message WorkspaceMemoRelatedSetting {
|
|||
// nsfw_tags is the list of tags that mark content as NSFW for blurring.
|
||||
repeated string nsfw_tags = 10;
|
||||
}
|
||||
|
||||
message WorkspaceAISetting {
|
||||
// enable_ai enables AI features.
|
||||
bool enable_ai = 1;
|
||||
// base_url is the base URL for AI API.
|
||||
string base_url = 2;
|
||||
// api_key is the API key for AI service.
|
||||
string api_key = 3;
|
||||
// model is the AI model to use.
|
||||
string model = 4;
|
||||
// timeout_seconds is the timeout for AI requests in seconds.
|
||||
int32 timeout_seconds = 5;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, request *v1pb.Ge
|
|||
_, err = s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||||
case storepb.WorkspaceSettingKey_STORAGE:
|
||||
_, err = s.Store.GetWorkspaceStorageSetting(ctx)
|
||||
case storepb.WorkspaceSettingKey_AI:
|
||||
_, err = s.Store.GetWorkspaceAISetting(ctx)
|
||||
default:
|
||||
return nil, status.Errorf(codes.InvalidArgument, "unsupported workspace setting key: %v", workspaceSettingKey)
|
||||
}
|
||||
|
|
@ -64,8 +66,8 @@ func (s *APIV1Service) GetWorkspaceSetting(ctx context.Context, request *v1pb.Ge
|
|||
return nil, status.Errorf(codes.NotFound, "workspace setting not found")
|
||||
}
|
||||
|
||||
// For storage setting, only host can get it.
|
||||
if workspaceSetting.Key == storepb.WorkspaceSettingKey_STORAGE {
|
||||
// For storage and AI settings, only host can get them.
|
||||
if workspaceSetting.Key == storepb.WorkspaceSettingKey_STORAGE || workspaceSetting.Key == storepb.WorkspaceSettingKey_AI {
|
||||
user, err := s.GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
|
|
@ -116,6 +118,10 @@ func convertWorkspaceSettingFromStore(setting *storepb.WorkspaceSetting) *v1pb.W
|
|||
workspaceSetting.Value = &v1pb.WorkspaceSetting_MemoRelatedSetting_{
|
||||
MemoRelatedSetting: convertWorkspaceMemoRelatedSettingFromStore(setting.GetMemoRelatedSetting()),
|
||||
}
|
||||
case *storepb.WorkspaceSetting_AiSetting:
|
||||
workspaceSetting.Value = &v1pb.WorkspaceSetting_AiSetting_{
|
||||
AiSetting: convertWorkspaceAISettingFromStore(setting.GetAiSetting()),
|
||||
}
|
||||
}
|
||||
return workspaceSetting
|
||||
}
|
||||
|
|
@ -141,6 +147,10 @@ func convertWorkspaceSettingToStore(setting *v1pb.WorkspaceSetting) *storepb.Wor
|
|||
workspaceSetting.Value = &storepb.WorkspaceSetting_MemoRelatedSetting{
|
||||
MemoRelatedSetting: convertWorkspaceMemoRelatedSettingToStore(setting.GetMemoRelatedSetting()),
|
||||
}
|
||||
case storepb.WorkspaceSettingKey_AI:
|
||||
workspaceSetting.Value = &storepb.WorkspaceSetting_AiSetting{
|
||||
AiSetting: convertWorkspaceAISettingToStore(setting.GetAiSetting()),
|
||||
}
|
||||
}
|
||||
return workspaceSetting
|
||||
}
|
||||
|
|
@ -279,6 +289,50 @@ func convertWorkspaceMemoRelatedSettingToStore(setting *v1pb.WorkspaceSetting_Me
|
|||
}
|
||||
}
|
||||
|
||||
func convertWorkspaceAISettingFromStore(setting *storepb.WorkspaceAISetting) *v1pb.WorkspaceSetting_AiSetting {
|
||||
if setting == nil {
|
||||
return &v1pb.WorkspaceSetting_AiSetting{
|
||||
EnableAi: false,
|
||||
BaseUrl: "",
|
||||
ApiKey: "",
|
||||
Model: "",
|
||||
TimeoutSeconds: 10,
|
||||
}
|
||||
}
|
||||
|
||||
result := &v1pb.WorkspaceSetting_AiSetting{
|
||||
EnableAi: setting.EnableAi,
|
||||
BaseUrl: setting.BaseUrl,
|
||||
ApiKey: setting.ApiKey,
|
||||
Model: setting.Model,
|
||||
TimeoutSeconds: setting.TimeoutSeconds,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func convertWorkspaceAISettingToStore(setting *v1pb.WorkspaceSetting_AiSetting) *storepb.WorkspaceAISetting {
|
||||
if setting == nil {
|
||||
return &storepb.WorkspaceAISetting{
|
||||
EnableAi: false,
|
||||
BaseUrl: "",
|
||||
ApiKey: "",
|
||||
Model: "",
|
||||
TimeoutSeconds: 10,
|
||||
}
|
||||
}
|
||||
|
||||
result := &storepb.WorkspaceAISetting{
|
||||
EnableAi: setting.EnableAi,
|
||||
BaseUrl: setting.BaseUrl,
|
||||
ApiKey: setting.ApiKey,
|
||||
Model: setting.Model,
|
||||
TimeoutSeconds: setting.TimeoutSeconds,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var ownerCache *v1pb.User
|
||||
|
||||
func (s *APIV1Service) GetInstanceOwner(ctx context.Context) (*v1pb.User, error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package store
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
|
|
@ -37,6 +39,8 @@ func (s *Store) UpsertWorkspaceSetting(ctx context.Context, upsert *storepb.Work
|
|||
valueBytes, err = protojson.Marshal(upsert.GetStorageSetting())
|
||||
} else if upsert.Key == storepb.WorkspaceSettingKey_MEMO_RELATED {
|
||||
valueBytes, err = protojson.Marshal(upsert.GetMemoRelatedSetting())
|
||||
} else if upsert.Key == storepb.WorkspaceSettingKey_AI {
|
||||
valueBytes, err = protojson.Marshal(upsert.GetAiSetting())
|
||||
} else {
|
||||
return nil, errors.Errorf("unsupported workspace setting key: %v", upsert.Key)
|
||||
}
|
||||
|
|
@ -208,6 +212,64 @@ func (s *Store) GetWorkspaceStorageSetting(ctx context.Context) (*storepb.Worksp
|
|||
return workspaceStorageSetting, nil
|
||||
}
|
||||
|
||||
const (
|
||||
defaultAIEnabled = false
|
||||
defaultAITimeoutSeconds = int32(15)
|
||||
)
|
||||
|
||||
func (s *Store) GetWorkspaceAISetting(ctx context.Context) (*storepb.WorkspaceAISetting, error) {
|
||||
workspaceSetting, err := s.GetWorkspaceSetting(ctx, &FindWorkspaceSetting{
|
||||
Name: storepb.WorkspaceSettingKey_AI.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get workspace AI setting")
|
||||
}
|
||||
|
||||
workspaceAISetting := &storepb.WorkspaceAISetting{}
|
||||
if workspaceSetting != nil {
|
||||
workspaceAISetting = workspaceSetting.GetAiSetting()
|
||||
} else {
|
||||
// If no database setting exists, use environment variables as defaults
|
||||
workspaceAISetting = loadAISettingFromEnv()
|
||||
}
|
||||
|
||||
// Set default timeout if not configured
|
||||
if workspaceAISetting.TimeoutSeconds <= 0 {
|
||||
workspaceAISetting.TimeoutSeconds = defaultAITimeoutSeconds
|
||||
}
|
||||
|
||||
s.workspaceSettingCache.Set(ctx, storepb.WorkspaceSettingKey_AI.String(), &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey_AI,
|
||||
Value: &storepb.WorkspaceSetting_AiSetting{AiSetting: workspaceAISetting},
|
||||
})
|
||||
return workspaceAISetting, nil
|
||||
}
|
||||
|
||||
// loadAISettingFromEnv loads AI configuration from environment variables
|
||||
func loadAISettingFromEnv() *storepb.WorkspaceAISetting {
|
||||
timeoutSeconds := defaultAITimeoutSeconds
|
||||
if timeoutStr := os.Getenv("AI_TIMEOUT_SECONDS"); timeoutStr != "" {
|
||||
if timeout, err := strconv.Atoi(timeoutStr); err == nil && timeout > 0 {
|
||||
timeoutSeconds = int32(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := os.Getenv("AI_BASE_URL")
|
||||
apiKey := os.Getenv("AI_API_KEY")
|
||||
model := os.Getenv("AI_MODEL")
|
||||
|
||||
// Enable AI if all required fields are provided via environment variables
|
||||
enableAI := baseURL != "" && apiKey != "" && model != ""
|
||||
|
||||
return &storepb.WorkspaceAISetting{
|
||||
EnableAi: enableAI,
|
||||
BaseUrl: baseURL,
|
||||
ApiKey: apiKey,
|
||||
Model: model,
|
||||
TimeoutSeconds: timeoutSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func convertWorkspaceSettingFromRaw(workspaceSettingRaw *WorkspaceSetting) (*storepb.WorkspaceSetting, error) {
|
||||
workspaceSetting := &storepb.WorkspaceSetting{
|
||||
Key: storepb.WorkspaceSettingKey(storepb.WorkspaceSettingKey_value[workspaceSettingRaw.Name]),
|
||||
|
|
@ -237,6 +299,12 @@ func convertWorkspaceSettingFromRaw(workspaceSettingRaw *WorkspaceSetting) (*sto
|
|||
return nil, err
|
||||
}
|
||||
workspaceSetting.Value = &storepb.WorkspaceSetting_MemoRelatedSetting{MemoRelatedSetting: memoRelatedSetting}
|
||||
case storepb.WorkspaceSettingKey_AI.String():
|
||||
aiSetting := &storepb.WorkspaceAISetting{}
|
||||
if err := protojsonUnmarshaler.Unmarshal([]byte(workspaceSettingRaw.Value), aiSetting); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceSetting.Value = &storepb.WorkspaceSetting_AiSetting{AiSetting: aiSetting}
|
||||
default:
|
||||
// Skip unsupported workspace setting key.
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
import { isEqual } from "lodash-es";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useState } 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 { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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";
|
||||
|
||||
const AISettings = observer(() => {
|
||||
const t = useTranslate();
|
||||
const [originalSetting, setOriginalSetting] = useState<WorkspaceSetting_AiSetting>(workspaceStore.state.aiSetting);
|
||||
const [aiSetting, setAiSetting] = useState<WorkspaceSetting_AiSetting>(originalSetting);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const updatePartialSetting = (partial: Partial<WorkspaceSetting_AiSetting>) => {
|
||||
setAiSetting(WorkspaceSetting_AiSetting.fromPartial({ ...aiSetting, ...partial }));
|
||||
};
|
||||
|
||||
const saveSetting = async (settingToSave: WorkspaceSetting_AiSetting) => {
|
||||
try {
|
||||
await workspaceStore.updateWorkspaceSetting({
|
||||
name: `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.AI}`,
|
||||
aiSetting: settingToSave,
|
||||
});
|
||||
setOriginalSetting(settingToSave);
|
||||
setAiSetting(settingToSave);
|
||||
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 updateEnableAI = async (enabled: boolean) => {
|
||||
const newSetting = WorkspaceSetting_AiSetting.fromPartial({ ...aiSetting, enableAi: enabled });
|
||||
await saveSetting(newSetting);
|
||||
};
|
||||
|
||||
const updateSetting = async () => {
|
||||
if (aiSetting.enableAi && (!aiSetting.apiKey || !aiSetting.model)) {
|
||||
toast.error(t("setting.ai-section.api-key-model-required"));
|
||||
return;
|
||||
}
|
||||
|
||||
const settingToSave = WorkspaceSetting_AiSetting.fromPartial({
|
||||
...aiSetting,
|
||||
baseUrl: aiSetting.baseUrl || "https://api.openai.com/v1",
|
||||
timeoutSeconds: aiSetting.timeoutSeconds || 10,
|
||||
});
|
||||
|
||||
await saveSetting(settingToSave);
|
||||
};
|
||||
|
||||
const resetSetting = () => setAiSetting(originalSetting);
|
||||
|
||||
// 只比较全局AI配置的变化,不包括子功能配置
|
||||
const globalSettingChanged = !isEqual(
|
||||
{
|
||||
enableAi: originalSetting.enableAi,
|
||||
baseUrl: originalSetting.baseUrl,
|
||||
apiKey: originalSetting.apiKey,
|
||||
model: originalSetting.model,
|
||||
timeoutSeconds: originalSetting.timeoutSeconds,
|
||||
},
|
||||
{
|
||||
enableAi: aiSetting.enableAi,
|
||||
baseUrl: aiSetting.baseUrl,
|
||||
apiKey: aiSetting.apiKey,
|
||||
model: aiSetting.model,
|
||||
timeoutSeconds: aiSetting.timeoutSeconds,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-6 pt-2 pb-4">
|
||||
{/* Global AI Settings */}
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<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-400">{t("setting.ai-section.title")}</span>
|
||||
<Badge variant={aiSetting.enableAi ? "default" : "secondary"}>
|
||||
{aiSetting.enableAi ? t("common.enabled") : t("common.disabled")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{t("setting.ai-section.description")}</p>
|
||||
|
||||
<div className="w-full flex flex-col gap-4 mt-4">
|
||||
{/* Enable AI Toggle */}
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="enable-ai">{t("setting.ai-section.enable-ai")}</Label>
|
||||
<span className="text-sm text-gray-500">{t("setting.ai-section.enable-ai-description")}</span>
|
||||
</div>
|
||||
<Switch id="enable-ai" checked={aiSetting.enableAi} onCheckedChange={updateEnableAI} />
|
||||
</div>
|
||||
|
||||
{/* AI Global Configuration Fields */}
|
||||
{aiSetting.enableAi && (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="base-url">{t("setting.ai-section.base-url")}</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
type="url"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={aiSetting.baseUrl}
|
||||
onChange={(e) => updatePartialSetting({ baseUrl: e.target.value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{t("setting.ai-section.base-url-description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="api-key">{t("setting.ai-section.api-key")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="api-key"
|
||||
type="text"
|
||||
placeholder="sk-..."
|
||||
value={aiSetting.apiKey}
|
||||
onChange={(e) => updatePartialSetting({ apiKey: e.target.value })}
|
||||
autoComplete="off"
|
||||
style={
|
||||
showApiKey
|
||||
? {}
|
||||
: ({
|
||||
WebkitTextSecurity: "disc",
|
||||
fontFamily: "text-security-disc, -webkit-small-control",
|
||||
} as React.CSSProperties)
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1 h-7 w-7 p-0"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOffIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{t("setting.ai-section.api-key-description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="model">{t("setting.ai-section.model")}</Label>
|
||||
<Input
|
||||
id="model"
|
||||
type="text"
|
||||
placeholder="gpt-4o, claude-3-5-sonnet-20241022..."
|
||||
value={aiSetting.model}
|
||||
onChange={(e) => updatePartialSetting({ model: e.target.value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{t("setting.ai-section.model-description")}</span>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Label htmlFor="timeout">{t("setting.ai-section.timeout")}</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
min="5"
|
||||
max="60"
|
||||
placeholder="10"
|
||||
value={aiSetting.timeoutSeconds}
|
||||
onChange={(e) => updatePartialSetting({ timeoutSeconds: parseInt(e.target.value) || 10 })}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{t("setting.ai-section.timeout-description")}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{aiSetting.enableAi && (
|
||||
<div className="w-full flex flex-row justify-end items-center gap-2 mt-4">
|
||||
<Button variant="outline" onClick={resetSetting} disabled={!globalSettingChanged}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={updateSetting} disabled={!globalSettingChanged}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AISettings;
|
||||
|
|
@ -38,8 +38,10 @@
|
|||
"days": "Days",
|
||||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
"disabled": "Disabled",
|
||||
"edit": "Edit",
|
||||
"email": "Email",
|
||||
"enabled": "Enabled",
|
||||
"expand": "Expand",
|
||||
"explore": "Explore",
|
||||
"file": "File",
|
||||
|
|
@ -189,6 +191,7 @@
|
|||
"restored-successfully": "Restored successfully",
|
||||
"succeed-copy-link": "Link copied successfully.",
|
||||
"update-succeed": "Update succeeded",
|
||||
"update-failed": "Update failed",
|
||||
"user-not-found": "User not found"
|
||||
},
|
||||
"reference": {
|
||||
|
|
@ -304,11 +307,46 @@
|
|||
},
|
||||
"my-account": "My Account",
|
||||
"preference": "Preferences",
|
||||
"ai": "AI",
|
||||
"preference-section": {
|
||||
"default-memo-sort-option": "Memo display time",
|
||||
"default-memo-visibility": "Default memo visibility",
|
||||
"theme": "Theme"
|
||||
},
|
||||
"ai-section": {
|
||||
"title": "AI Settings",
|
||||
"description": "Configure AI services for enhanced features like tag recommendations.",
|
||||
"enable-ai": "Enable AI Features",
|
||||
"enable-ai-description": "Turn on AI-powered features throughout the application",
|
||||
"base-url": "Base URL",
|
||||
"base-url-description": "API endpoint for your AI service (e.g., https://api.openai.com/v1)",
|
||||
"api-key": "API Key",
|
||||
"api-key-description": "Authentication key for your AI service",
|
||||
"model": "Model",
|
||||
"model-description": "AI model to use for processing requests (e.g., gpt-4o, claude-3-5-sonnet-20241022)",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"timeout-description": "Request timeout in seconds (5-60)",
|
||||
"test-connection": "Test Connection",
|
||||
"testing-connection": "Testing...",
|
||||
"test-connection-description": "Verify that your AI service configuration is working",
|
||||
"test-connection-success": "Connection successful! AI service is properly configured.",
|
||||
"test-connection-failed": "Connection failed. Please check your configuration.",
|
||||
"test-connection-incomplete": "Please fill in all required fields before testing.",
|
||||
"fields-required": "All fields are required when AI is enabled.",
|
||||
"api-key-model-required": "API key and model are required when AI is enabled."
|
||||
},
|
||||
"tag-recommendation": {
|
||||
"title": "Tag Recommendation",
|
||||
"description": "Use AI to recommend suitable tags for your memo content",
|
||||
"enable": "Enable Tag Recommendation",
|
||||
"enable-description": "Get AI-powered tag suggestions in the memo editor",
|
||||
"system-prompt": "Custom System Prompt",
|
||||
"system-prompt-placeholder": "Leave empty to use default prompt...",
|
||||
"system-prompt-description": "Customize the AI instruction for tag recommendations. Leave empty to use the built-in default prompt",
|
||||
"rate-limit": "Rate Limit (requests/minute)",
|
||||
"rate-limit-description": "Maximum number of tag recommendation requests per minute"
|
||||
},
|
||||
"ai-features": "AI Features",
|
||||
"sso": "SSO",
|
||||
"sso-section": {
|
||||
"authorization-endpoint": "Authorization endpoint",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@
|
|||
"days": "天",
|
||||
"delete": "删除",
|
||||
"description": "说明",
|
||||
"disabled": "已禁用",
|
||||
"edit": "编辑",
|
||||
"email": "邮箱",
|
||||
"enabled": "已启用",
|
||||
"expand": "展开",
|
||||
"explore": "发现",
|
||||
"file": "文件",
|
||||
|
|
@ -303,11 +305,34 @@
|
|||
},
|
||||
"my-account": "我的账号",
|
||||
"preference": "偏好设置",
|
||||
"ai": "AI",
|
||||
"preference-section": {
|
||||
"default-memo-sort-option": "备忘录显示时间",
|
||||
"default-memo-visibility": "默认备忘录可见性",
|
||||
"theme": "主题"
|
||||
},
|
||||
"ai-section": {
|
||||
"title": "AI 设置",
|
||||
"description": "配置 AI 服务以启用标签推荐等增强功能。",
|
||||
"enable-ai": "启用 AI 功能",
|
||||
"enable-ai-description": "在应用程序中开启 AI 驱动的功能",
|
||||
"base-url": "基础 URL",
|
||||
"base-url-description": "AI 服务的 API 端点(例如:https://api.openai.com/v1)",
|
||||
"api-key": "API 密钥",
|
||||
"api-key-description": "AI 服务的身份验证密钥",
|
||||
"model": "模型",
|
||||
"model-description": "用于处理请求的 AI 模型(例如:gpt-4o, claude-3-5-sonnet-20241022)",
|
||||
"timeout": "超时(秒)",
|
||||
"timeout-description": "请求超时时间,单位秒(5-60)",
|
||||
"test-connection": "测试连接",
|
||||
"testing-connection": "测试中...",
|
||||
"test-connection-description": "验证您的 AI 服务配置是否正常工作",
|
||||
"test-connection-success": "连接成功!AI 服务配置正确。",
|
||||
"test-connection-failed": "连接失败。请检查您的配置。",
|
||||
"test-connection-incomplete": "请在测试前填写所有必填字段。",
|
||||
"fields-required": "启用 AI 时,所有字段都是必填的。",
|
||||
"api-key-model-required": "启用 AI 时,API 密钥和模型是必填的。"
|
||||
},
|
||||
"sso": "单点登录",
|
||||
"sso-section": {
|
||||
"authorization-endpoint": "授权端点(Authorization Endpoint)",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, UserIcon, UsersIcon } from "lucide-react";
|
||||
import { CogIcon, DatabaseIcon, KeyIcon, LibraryIcon, LucideIcon, Settings2Icon, SparklesIcon, UserIcon, UsersIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import MobileHeader from "@/components/MobileHeader";
|
||||
import AISettings from "@/components/Settings/AISettings";
|
||||
import MemberSection from "@/components/Settings/MemberSection";
|
||||
import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings";
|
||||
import MyAccountSection from "@/components/Settings/MyAccountSection";
|
||||
|
|
@ -19,14 +20,14 @@ import { User_Role } from "@/types/proto/api/v1/user_service";
|
|||
import { WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
type SettingSection = "my-account" | "preference" | "member" | "system" | "memo-related" | "storage" | "sso";
|
||||
type SettingSection = "my-account" | "preference" | "member" | "system" | "memo-related" | "storage" | "sso" | "ai";
|
||||
|
||||
interface State {
|
||||
selectedSection: SettingSection;
|
||||
}
|
||||
|
||||
const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference"];
|
||||
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo-related", "storage", "sso"];
|
||||
const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo-related", "storage", "ai", "sso"];
|
||||
const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
|
||||
"my-account": UserIcon,
|
||||
preference: CogIcon,
|
||||
|
|
@ -34,6 +35,7 @@ const SECTION_ICON_MAP: Record<SettingSection, LucideIcon> = {
|
|||
system: Settings2Icon,
|
||||
"memo-related": LibraryIcon,
|
||||
storage: DatabaseIcon,
|
||||
ai: SparklesIcon,
|
||||
sso: KeyIcon,
|
||||
};
|
||||
|
||||
|
|
@ -73,7 +75,7 @@ const Setting = observer(() => {
|
|||
|
||||
// Initial fetch for workspace settings.
|
||||
(async () => {
|
||||
[WorkspaceSetting_Key.MEMO_RELATED, WorkspaceSetting_Key.STORAGE].forEach(async (key) => {
|
||||
[WorkspaceSetting_Key.MEMO_RELATED, WorkspaceSetting_Key.STORAGE, WorkspaceSetting_Key.AI].forEach(async (key) => {
|
||||
await workspaceStore.fetchWorkspaceSetting(key);
|
||||
});
|
||||
})();
|
||||
|
|
@ -148,6 +150,8 @@ const Setting = observer(() => {
|
|||
<MemoRelatedSettings />
|
||||
) : state.selectedSection === "storage" ? (
|
||||
<StorageSection />
|
||||
) : state.selectedSection === "ai" ? (
|
||||
<AISettings />
|
||||
) : state.selectedSection === "sso" ? (
|
||||
<SSOSection />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { WorkspaceProfile, WorkspaceSetting_Key } from "@/types/proto/api/v1/wor
|
|||
import {
|
||||
WorkspaceSetting_GeneralSetting,
|
||||
WorkspaceSetting_MemoRelatedSetting,
|
||||
WorkspaceSetting_AiSetting,
|
||||
WorkspaceSetting,
|
||||
} from "@/types/proto/api/v1/workspace_service";
|
||||
import { isValidateLocale } from "@/utils/i18n";
|
||||
|
|
@ -30,6 +31,19 @@ class LocalState {
|
|||
);
|
||||
}
|
||||
|
||||
get aiSetting() {
|
||||
return (
|
||||
this.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.AI}`)?.aiSetting ||
|
||||
WorkspaceSetting_AiSetting.fromPartial({
|
||||
enableAi: false,
|
||||
baseUrl: "",
|
||||
apiKey: "",
|
||||
model: "",
|
||||
timeoutSeconds: 10,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
|
@ -66,6 +80,14 @@ const workspaceStore = (() => {
|
|||
});
|
||||
};
|
||||
|
||||
const updateWorkspaceSetting = async (setting: WorkspaceSetting) => {
|
||||
const response = await workspaceServiceClient.updateWorkspaceSetting({ setting });
|
||||
state.setPartial({
|
||||
settings: uniqBy([response, ...state.settings], "name"),
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
const getWorkspaceSettingByKey = (settingKey: WorkspaceSetting_Key) => {
|
||||
return (
|
||||
state.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${settingKey}`) || WorkspaceSetting.fromPartial({})
|
||||
|
|
@ -96,6 +118,7 @@ const workspaceStore = (() => {
|
|||
state,
|
||||
fetchWorkspaceSetting,
|
||||
upsertWorkspaceSetting,
|
||||
updateWorkspaceSetting,
|
||||
getWorkspaceSettingByKey,
|
||||
setTheme,
|
||||
};
|
||||
|
|
@ -104,7 +127,7 @@ const workspaceStore = (() => {
|
|||
export const initialWorkspaceStore = async () => {
|
||||
const workspaceProfile = await workspaceServiceClient.getWorkspaceProfile({});
|
||||
// Prepare workspace settings.
|
||||
for (const key of [WorkspaceSetting_Key.GENERAL, WorkspaceSetting_Key.MEMO_RELATED]) {
|
||||
for (const key of [WorkspaceSetting_Key.GENERAL, WorkspaceSetting_Key.MEMO_RELATED, WorkspaceSetting_Key.AI]) {
|
||||
await workspaceStore.fetchWorkspaceSetting(key);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue