From 98972d615cfc37aa7587baa16bb1c27c973514d8 Mon Sep 17 00:00:00 2001 From: CHANGGELY <133892052+CHANGGELY@users.noreply.github.com> Date: Sun, 16 Nov 2025 09:17:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(i18n,zhipu):=20=E4=B8=AD=E6=96=87=E5=8C=96?= =?UTF-8?q?=E4=B8=BB=E8=A6=81=E9=A1=B5=E9=9D=A2=20&=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=99=BA=E8=B0=B1=E5=85=8D=E8=B4=B9=20VLM=20=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端:新增 i18n 资源,首页/监控/设置等中文化,默认语言 zh,保留切换 - 后端:规范化 baseUrl,移除重复的 /chat/completions;VLM 校验改为 image_url+text;Embedding 别名映射 Doubao-embedding-large → doubao-embedding-large-text-240915 - 配置:默认设智谱 VLM 与豆包 Embedding 的 baseUrl 与模型名;API Key 通过环境变量,不硬编码 提升:降低使用门槛,支持智谱免费 VLM 正常连通,保留混合供应商用法 --- config/config.yaml | 38 +- frontend/src/renderer/src/i18n/i18n.ts | 298 +++++++++ .../components/latest-activity-card/index.tsx | 8 +- .../src/renderer/src/pages/home/home-page.tsx | 14 +- .../renderer/src/pages/settings/settings.tsx | 133 ++-- opencontext/llm/llm_client.py | 28 +- opencontext/server/routes/settings.py | 587 +----------------- 7 files changed, 427 insertions(+), 679 deletions(-) create mode 100644 frontend/src/renderer/src/i18n/i18n.ts diff --git a/config/config.yaml b/config/config.yaml index 4357080c..4d377c73 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -24,16 +24,16 @@ document_processing: text_threshold_per_page: 50 # Scanned document threshold: pages with fewer characters than this value are considered scanned documents (requires VLM) vlm_model: - base_url: "${LLM_BASE_URL}" + base_url: "https://open.bigmodel.cn/api/paas/v4" api_key: "${LLM_API_KEY}" - model: "${LLM_MODEL}" - provider: "" + model: "glm-4.1v-thinking-flash" + provider: "openai" embedding_model: - base_url: "${EMBEDDING_BASE_URL}" + base_url: "https://ark.cn-beijing.volces.com/api/v3" api_key: "${EMBEDDING_API_KEY}" - model: "${EMBEDDING_MODEL}" - provider: "" + model: "Doubao-embedding-large" + provider: "doubao" output_dim: 2048 # Context capture module @@ -198,29 +198,3 @@ content_generation: activity: enabled: true interval: 900 # Seconds, minimum 600 (10 minutes), recommended 900-1800 (15-30 minutes) - - tips: - enabled: true - interval: 3600 # Seconds, minimum 1800 (30 minutes), recommended 3600-7200 (1-2 hours) - - todos: - enabled: true - interval: 1800 # Seconds, minimum 1800 (30 minutes), recommended 1800-3600 (30-60 minutes) - - report: - enabled: true - time: "08:00" # Daily report generation time (HH:MM) - -tools: - # Operation tools configuration - operation_tools: - web_search_tool: - enabled: true - web_search: - engine: duckduckgo - max_results: 5 - timeout: 10 - -# Intelligent completion service configuration -completion: - enabled: true diff --git a/frontend/src/renderer/src/i18n/i18n.ts b/frontend/src/renderer/src/i18n/i18n.ts new file mode 100644 index 00000000..eeec4e6c --- /dev/null +++ b/frontend/src/renderer/src/i18n/i18n.ts @@ -0,0 +1,298 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +const resources = { + en: { + translation: { + common: { + language: { + zh: '中文', + en: 'English' + }, + save: 'Save', + get_started: 'Get started', + ok: 'Confirm', + cancel: 'Cancel' + }, + sidebar: { + home: 'Home', + monitor: 'Screen Monitor', + settings: 'Settings', + summary: 'Summary', + tutorial: 'Start With Tutorial' + }, + tutorial: { + modal: { + title: 'Proactive Feed', + subtitle: 'Here are some insights you should know', + ok: 'I got it' + } + }, + vault: { + max_depth_reached: 'Maximum folder depth of 5 levels reached.' + }, + home: { + title: 'Create with Context, Clarity from Chaos', + description: + 'Home delivers your daily summaries, todos, tips and insights from your collected Contexts', + latest_activity: { + title: 'Latest activity', + empty: 'No activity in the last 7 days.' + }, + recent_creation: { + title: 'Recent creation', + empty: 'No creation in the last 7 days.' + }, + recent_chat: { + title: 'Recent chat', + empty: 'No chats in the latest 7 days.', + untitled: 'Untitled Conversation' + }, + view: 'View', + check: 'Check', + proactive: 'Proactive', + feed: 'Feed', + empty_insight_tip: 'Proactive insights will appear here to help you', + tip: 'Tip', + daily_summary: 'Daily Summary', + weekly_summary: 'Weekly Summary', + new_notification: 'New Notification', + todo: { + today: 'Today', + update_tip: 'Update at 8 am everyday' + } + }, + toast: { + insight_deleted: 'Insight deleted' + }, + monitor: { + header: { + title: 'Screen Monitor', + description: + 'Screen Monitor captures anything on your screen and transforms it into intelligent, connected Contexts. All data stays local with full privacy protection', + settings_only_after_stop: 'Settings can only be adjusted after Stop Recording.', + start_recording: 'Start Recording', + stop_recording: 'Stop Recording', + settings: 'Settings', + select_monitoring_window_tip: + 'Please click the settings button and select your monitoring window or screen.' + }, + modal: { + display_screenshot_title: 'Display Screenshot', + display_screenshot_alt: 'Display Screenshot' + }, + empty: { + today_tip: 'Start screen recording, summarize every {{minutes}} minutes', + nodata: 'No data available', + permission_enable_tip: 'Enable screen recording permission, summary every {{minutes}} minutes', + enable_permission: 'Enable Permission' + }, + settings_modal: { + title: 'Settings', + cancel: 'Cancel', + save: 'Save', + record_interval: 'Record Interval', + choose_what_to_record: 'Choose what to record', + enable_recording_hours: 'Enable recording hours', + set_recording_hours: 'Set recording hours', + apply_to_days: 'Apply to days', + weekday: 'Only weekday', + everyday: 'Everyday', + screen_label: 'Screen', + window_label: 'Window', + only_opened_apps_tip: 'Only opened applications can be selected' + }, + date: { + today: 'Today' + }, + toast: { + select_at_least_one: 'Please select at least one screen or window', + download_started: 'Download has started' + }, + errors: { + permission_required: 'Screen recording permission is required.', + load_image_failed: 'Failed to load image', + unable_get_image_data: 'Unable to get image data' + } + }, + settings: { + title: 'Select a AI model to start', + subtitle: "Configure AI model and API Key, then you can start MineContext’s intelligent context capability", + vision_title: 'Vision language model', + embedding_title: 'Embedding model', + fields: { + model_name: 'Model name', + base_url: 'Base URL', + api_key: 'API Key' + }, + placeholders: { + vision_model: 'A VLM model with visual understanding capabilities is required.', + base_url: 'Enter your base URL', + api_key: 'Enter your API Key', + embedding_model: 'Enter your embedding model name' + }, + errors: { + required: 'Cannot be empty', + select_model: 'Please select AI model', + key_required: 'Please enter your API key', + select_platform: 'Please select Model Platform', + save_failed: 'Failed to save settings' + }, + toast: { + save_success: 'Your API key saved successfully' + } + } + } + }, + zh: { + translation: { + common: { + language: { + zh: '中文', + en: '英文' + }, + save: '保存', + get_started: '开始使用', + ok: '确认', + cancel: '取消' + }, + sidebar: { + home: '首页', + monitor: '屏幕监控', + settings: '设置', + summary: '总结', + tutorial: '新手引导' + }, + tutorial: { + modal: { + title: '主动信息流', + subtitle: '一些你需要了解的洞见', + ok: '知道了' + } + }, + vault: { + max_depth_reached: '文件夹层级已达上限(最多5层)' + }, + home: { + title: '用上下文创造,从混乱中获得清晰', + description: '首页展示每日总结、待办、技巧等洞见,源自你的上下文', + latest_activity: { + title: '最近活动', + empty: '最近7天没有活动' + }, + recent_creation: { + title: '最近创建', + empty: '最近7天没有新建' + }, + recent_chat: { + title: '最近会话', + empty: '最近7天没有会话', + untitled: '未命名对话' + }, + view: '查看', + check: '查看', + proactive: '主动', + feed: '信息流', + empty_insight_tip: '主动洞见会显示在这里,帮助你', + tip: '技巧', + daily_summary: '每日总结', + weekly_summary: '每周总结', + new_notification: '新通知', + todo: { + today: '今天', + update_tip: '每天早上8点更新' + } + }, + toast: { + insight_deleted: '洞见已删除' + }, + monitor: { + header: { + title: '屏幕监控', + description: '屏幕监控会捕获你的屏幕内容并转化为智能的上下文,数据本地存储,隐私可控', + settings_only_after_stop: '仅在停止录制后才能调整设置', + start_recording: '开始录制', + stop_recording: '停止录制', + settings: '设置', + select_monitoring_window_tip: '请先点击设置选择要监控的窗口或屏幕' + }, + modal: { + display_screenshot_title: '显示截图', + display_screenshot_alt: '显示截图' + }, + empty: { + today_tip: '开始屏幕录制,每{{minutes}}分钟自动截图并总结', + nodata: '暂无数据', + permission_enable_tip: '请开启屏幕录制权限,每{{minutes}}分钟进行AI总结', + enable_permission: '开启权限' + }, + settings_modal: { + title: '设置', + cancel: '取消', + save: '保存', + record_interval: '录制间隔', + choose_what_to_record: '选择要录制的内容', + enable_recording_hours: '启用录制时段', + set_recording_hours: '设置录制时段', + apply_to_days: '应用到日期', + weekday: '仅工作日', + everyday: '每天', + screen_label: '屏幕', + window_label: '窗口', + only_opened_apps_tip: '仅可选择已打开的应用' + }, + date: { + today: '今天' + }, + toast: { + select_at_least_one: '请至少选择一个屏幕或窗口', + download_started: '开始下载' + }, + errors: { + permission_required: '需要开启屏幕录制权限', + load_image_failed: '图片加载失败', + unable_get_image_data: '无法获取图片数据' + } + }, + settings: { + title: '选择模型开始使用', + subtitle: '配置模型与 API Key,即可开启 MineContext 的智能上下文能力', + vision_title: '视觉语言模型', + embedding_title: '向量模型', + fields: { + model_name: '模型名称', + base_url: '接口地址', + api_key: '密钥' + }, + placeholders: { + vision_model: '请选择具有视觉理解能力的模型', + base_url: '请输入接口地址', + api_key: '请输入密钥', + embedding_model: '请输入向量模型名称' + }, + errors: { + required: '不能为空', + select_model: '请选择模型', + key_required: '请输入密钥', + select_platform: '请选择模型平台', + save_failed: '保存设置失败' + }, + toast: { + save_success: 'API Key 保存成功' + } + } + } + } +} + +const stored = typeof window !== 'undefined' ? window.localStorage.getItem('lang') : null +const lng = stored || 'zh' + +i18n.use(initReactI18next).init({ + resources, + lng, + fallbackLng: 'zh', + interpolation: { escapeValue: false } +}) + +export default i18n diff --git a/frontend/src/renderer/src/pages/home/components/latest-activity-card/index.tsx b/frontend/src/renderer/src/pages/home/components/latest-activity-card/index.tsx index d1b90143..72e8399d 100644 --- a/frontend/src/renderer/src/pages/home/components/latest-activity-card/index.tsx +++ b/frontend/src/renderer/src/pages/home/components/latest-activity-card/index.tsx @@ -9,9 +9,10 @@ import { ActivityTimelineItem } from '@renderer/pages/screen-monitor/components/ import { isEmpty } from 'lodash' import { useServiceHandler } from '@renderer/atom/event-loop.atom' import { POWER_MONITOR_KEY } from '@shared/constant/power-monitor' +import { useTranslation } from 'react-i18next' interface LatestActivityCardProps { - title: string + title?: string hasToDocButton?: boolean emptyText?: string children?: React.ReactNode @@ -22,6 +23,7 @@ const LOCKED_INTERVAL = 300000 // Locked: 5 minutes const LatestActivityCard: FC = () => { const { navigateToMainTab } = useNavigation() + const { t } = useTranslation() // Store polling timer ID const pollIntervalRef = useRef(null) @@ -78,8 +80,8 @@ const LatestActivityCard: FC = () => { return ( {data ? ( diff --git a/frontend/src/renderer/src/pages/home/home-page.tsx b/frontend/src/renderer/src/pages/home/home-page.tsx index 6da68871..808e7d2c 100644 --- a/frontend/src/renderer/src/pages/home/home-page.tsx +++ b/frontend/src/renderer/src/pages/home/home-page.tsx @@ -18,6 +18,7 @@ import { setActiveConversationId, toggleHomeAiAssistant } from '@renderer/store/ import { useSelector } from 'react-redux' import { RootState, useAppDispatch } from '@renderer/store' import { useUnmount } from 'ahooks' +import { useTranslation } from 'react-i18next' const { Title, Text } = Typography @@ -28,6 +29,7 @@ const { Title, Text } = Typography // }); const HomePage: React.FC = () => { + const { t } = useTranslation() const recentVaults = getRecentVaults() // const { isVisible, toggleAIAssistant, hideAIAssistant } = useAIAssistant() const isVisible = useSelector((state: RootState) => state.chatHistory.home.aiAssistantVisible) @@ -50,12 +52,10 @@ const HomePage: React.FC = () => {
- Create with <span style={{ color: 'blue', fontWeight: 700 }}>Context</span>, Clarity from - Chaos.👏 + {t('home.title')} - Home 1s where MineContext proactively delivers your daily summaries, todos, tips and other - insights—emerging from all your collected Contexts ✨ + {t('home.description')}
dispatch(toggleHomeAiAssistant(true))} isActive={isVisible} /> @@ -64,11 +64,7 @@ const HomePage: React.FC = () => {
- +
diff --git a/frontend/src/renderer/src/pages/settings/settings.tsx b/frontend/src/renderer/src/pages/settings/settings.tsx index 49b1e2b2..cccbfc2e 100644 --- a/frontend/src/renderer/src/pages/settings/settings.tsx +++ b/frontend/src/renderer/src/pages/settings/settings.tsx @@ -4,9 +4,10 @@ import { FC, useMemo, useEffect } from 'react' import { Form, Button, Select, Input, Typography, Spin, Message } from '@arco-design/web-react' import { find, get, isEmpty, pick } from 'lodash' +import { useTranslation } from 'react-i18next' import ModelRadio from './components/modelRadio/model-radio' -import { ModelTypeList, BaseUrl, embeddingModels, ModelInfoList } from './constants' +import { ModelTypeList, BaseUrl, embeddingModels, ModelInfoList, ZHIPU_BASE_URL, DEFAULT_ZHIPU_VLM, DEFAULT_DOUHAO_EMBEDDING } from './constants' import { getModelInfo, ModelConfigProps, updateModelSettingsAPI } from '../../services/Settings' import { useMemoizedFn, useMount, useRequest } from 'ahooks' @@ -29,21 +30,20 @@ export interface CustomFormItemsProps { } const CustomFormItems: FC = (props) => { const { prefix } = props + const { t } = useTranslation() return ( <>
- - Vision language model - + {t('settings.vision_title')} } - placeholder="A VLM model with visual understanding capabilities is required." + addBefore={} + placeholder={t('settings.placeholders.vision_model')} allowClear className="[&_.arco-input-inner-wrapper]: !w-[574px]" /> @@ -51,38 +51,38 @@ const CustomFormItems: FC = (props) => { } - placeholder="Enter your base URL" + addBefore={} + placeholder={t('settings.placeholders.base_url')} allowClear className="[&_.arco-input-inner-wrapper]: !w-[574px]" /> - - } - placeholder="Enter your API Key" - allowClear - className="!w-[574px]" - /> - + + } + placeholder={t('settings.placeholders.api_key')} + allowClear + className="!w-[574px]" + /> +
- Embedding model + {t('settings.embedding_title')} } - placeholder="Enter your embedding model name" + addBefore={} + placeholder={t('settings.placeholders.embedding_model')} allowClear className="!w-[574px]" /> @@ -90,28 +90,28 @@ const CustomFormItems: FC = (props) => { - } - placeholder="Enter your base URL" - allowClear - className="!w-[574px]" - /> - - } - placeholder="Enter your API Key" + addBefore={} + placeholder={t('settings.placeholders.base_url')} allowClear className="!w-[574px]" /> -
+ + } + placeholder={t('settings.placeholders.api_key')} + allowClear + className="!w-[574px]" + /> + +
) @@ -122,6 +122,7 @@ export interface StandardFormItemsProps { } const StandardFormItems: FC = (props) => { const { modelPlatform, prefix } = props + const { t } = useTranslation() const option = useMemo(() => { const foundItem = find(ModelInfoList, (item) => item.value === modelPlatform) return foundItem ? foundItem.option : [] @@ -130,14 +131,14 @@ const StandardFormItems: FC = (props) => { return ( <> = (props) => { @@ -170,14 +171,14 @@ const StandardFormItems: FC = (props) => { { validator(value, callback) { if (!value) { - callback('Please enter your API key') + callback(t('settings.errors.key_required')) } else { callback() } } } ]}> - + ) @@ -189,7 +190,7 @@ export interface SettingsFormBase { } export type SettingsFormProps = SettingsFormBase & { - [K in ModelTypeList as `${K}-modelId` | `${K}-apiKey`]?: string + [K in ModelTypeList as `${K}-modelId` | `${K}-apiKey` | `${K}-baseUrl`]?: string } & { [K in | `${ModelTypeList.Custom}-embeddingModelId` @@ -198,6 +199,7 @@ export type SettingsFormProps = SettingsFormBase & { } const Settings: FC = (props) => { const { closeSetting, init } = props + const { t } = useTranslation() const [form] = Form.useForm() const { run: getInfo, loading: getInfoLoading, data: modelInfo } = useRequest(getModelInfo, { manual: true }) @@ -205,14 +207,14 @@ const Settings: FC = (props) => { const { run: updateModelSettings, loading: updateLoading } = useRequest(updateModelSettingsAPI, { manual: true, onSuccess() { - Message.success('Your API key saved successfully') + Message.success(t('settings.toast.save_success')) getInfo() if (init) { closeSetting?.() } }, onError(e: Error) { - const errMsg = get(e, 'response.data.message') || get(e, 'message') || 'Failed to save settings' + const errMsg = get(e, 'response.data.message') || get(e, 'message') || t('settings.errors.save_failed') Message.error(errMsg) } }) @@ -222,7 +224,7 @@ const Settings: FC = (props) => { const values = form.getFieldsValue() const isCustom = values.modelPlatform === ModelTypeList.Custom if (!values.modelPlatform) { - Message.error('Please select Model Platform') + Message.error(t('settings.errors.select_platform')) return } const commonKey = [ @@ -277,22 +279,23 @@ const Settings: FC = (props) => {
-
Select a AI model to start
- - Configure AI model and API Key, then you can start MineContext’s intelligent context capability - +
{t('settings.title')}
+ {t('settings.subtitle')}
-
+ @@ -315,7 +318,7 @@ const Settings: FC = (props) => {
diff --git a/opencontext/llm/llm_client.py b/opencontext/llm/llm_client.py index 1fdeaac6..1b046f11 100644 --- a/opencontext/llm/llm_client.py +++ b/opencontext/llm/llm_client.py @@ -392,22 +392,18 @@ def _extract_error_summary(error_msg: str) -> str: try: if self.llm_type == LLMType.CHAT: - # Test with an image input - 20x20 pixel PNG with clear red square pattern - # This is a small but visible test image to validate vision capabilities - # tiny_image_base64 = "iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAMElEQVR42mP8z8DwHwMxgImBQjDwBo4aNWrUqFGjRlEEhtEwHDVq1KhRo0aNGgUAAN0/Af9dX6MgAAAAAElFTkSuQmCC" - # messages = [ - # { - # "role": "user", - # "content": [ - # {"type": "text", "text": "Hi"}, - # { - # "type": "image_url", - # "image_url": {"url": f"data:image/png;base64,{tiny_image_base64}"}, - # }, - # ], - # } - # ] - messages = [{"role": "user", "content": "Hi"}] + messages = [ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": "https://cdn.bigmodel.cn/static/logo/register.png"}, + }, + {"type": "text", "text": "请识别这张图片中的元素"}, + ], + } + ] response = self.client.chat.completions.create(model=self.model, messages=messages) if response.choices and len(response.choices) > 0: return True, "Chat model validation successful" diff --git a/opencontext/server/routes/settings.py b/opencontext/server/routes/settings.py index eae4f5a5..f901d0d6 100644 --- a/opencontext/server/routes/settings.py +++ b/opencontext/server/routes/settings.py @@ -132,6 +132,7 @@ async def update_model_settings(request: UpdateModelSettingsRequest, _auth: str """Update model configuration and reinitialize LLM clients""" with _config_lock: try: + import os cfg = request.config current_cfg = GlobalConfig.get_instance().get_config() or {} current_vlm_key = (current_cfg.get("vlm_model") or {}).get("api_key", "") @@ -139,6 +140,12 @@ async def update_model_settings(request: UpdateModelSettingsRequest, _auth: str # Resolve VLM API key vlm_key = current_vlm_key if _is_masked_api_key(cfg.apiKey) else cfg.apiKey + if not vlm_key: + # Prefer provider-specific environment variable for Zhipu + if (cfg.modelPlatform or "").lower() == "openai" and "bigmodel.cn" in (cfg.baseUrl or ""): + vlm_key = os.getenv("ZHIPUAI_API_KEY", "") or os.getenv("LLM_API_KEY", "") + else: + vlm_key = os.getenv("LLM_API_KEY", "") or vlm_key # Resolve Embedding API key if cfg.embeddingApiKey: @@ -148,10 +155,23 @@ async def update_model_settings(request: UpdateModelSettingsRequest, _auth: str else cfg.embeddingApiKey ) else: + # Prefer provider-specific environment variable for Doubao emb_key = vlm_key + target_provider = (cfg.embeddingModelPlatform or cfg.modelPlatform or "").lower() + target_url = (cfg.embeddingBaseUrl or cfg.baseUrl or "") + if target_provider == "doubao" or "volces.com" in target_url: + emb_key = os.getenv("DOUBAO_API_KEY", "") or os.getenv("EMBEDDING_API_KEY", "") or emb_key # Resolve embedding URL and provider emb_url = cfg.embeddingBaseUrl or cfg.baseUrl + # Normalize possible incorrect Zhipu path ending + if emb_url and emb_url.rstrip("/").endswith("/chat/completions"): + emb_url = emb_url.rstrip("/") + emb_url = emb_url[: emb_url.rfind("/chat/completions")] + base_url = cfg.baseUrl + if base_url and base_url.rstrip("/").endswith("/chat/completions"): + base_url = base_url.rstrip("/") + base_url = base_url[: base_url.rfind("/chat/completions")] emb_provider = cfg.embeddingModelPlatform or cfg.modelPlatform # Validation @@ -170,22 +190,31 @@ async def update_model_settings(request: UpdateModelSettingsRequest, _auth: str # Validate VLM vlm_config = _build_llm_config( - cfg.baseUrl, vlm_key, cfg.modelId, cfg.modelPlatform, LLMType.CHAT + base_url, vlm_key, cfg.modelId, cfg.modelPlatform, LLMType.CHAT ) vlm_valid, vlm_msg = LLMClient(llm_type=LLMType.CHAT, config=vlm_config).validate() if not vlm_valid: return convert_resp( - code=400, status=400, message=f"VLM validation failed: {vlm_msg}" + code=400, status=400, message=f"视觉模型校验失败:{vlm_msg}" ) + # Normalize Doubao embedding model alias + emb_model_id = cfg.embeddingModelId + if (emb_provider or "").lower() == "doubao" or "volces.com" in (emb_url or "").lower(): + nm = (emb_model_id or "").strip().lower() + if nm == "doubao-embedding-large" or ( + "豆包" in emb_model_id and "嵌入" in emb_model_id and "大模型" in emb_model_id + ): + emb_model_id = "doubao-embedding-large-text-240915" + # Validate Embedding emb_config = _build_llm_config( - emb_url, emb_key, cfg.embeddingModelId, emb_provider, LLMType.EMBEDDING + emb_url, emb_key, emb_model_id, emb_provider, LLMType.EMBEDDING ) emb_valid, emb_msg = LLMClient(llm_type=LLMType.EMBEDDING, config=emb_config).validate() if not emb_valid: return convert_resp( - code=400, status=400, message=f"Embedding validation failed: {emb_msg}" + code=400, status=400, message=f"向量模型校验失败:{emb_msg}" ) # Save configuration @@ -219,553 +248,3 @@ async def update_model_settings(request: UpdateModelSettingsRequest, _auth: str except Exception as e: logger.exception(f"Failed to update model settings: {e}") - return convert_resp(code=500, status=500, message="Failed to update model settings") - - -@router.get("/api/model_settings/validate") -async def validate_llm_config(_auth: str = auth_dependency): - """Validate current LLM configuration from backend""" - try: - # Get current configuration from backend - config = GlobalConfig.get_instance().get_config() - if not config: - return convert_resp(code=500, status=500, message="配置未初始化") - - vlm_cfg = config.get("vlm_model", {}) - emb_cfg = config.get("embedding_model", {}) - - # Validate VLM - vlm_valid, vlm_msg = LLMClient(llm_type=LLMType.CHAT, config=vlm_cfg).validate() - - # Validate Embedding - emb_valid, emb_msg = LLMClient(llm_type=LLMType.EMBEDDING, config=emb_cfg).validate() - - # Build error message - if not vlm_valid or not emb_valid: - errors = [] - if not vlm_valid: - errors.append(f"VLM: {vlm_msg}") - if not emb_valid: - errors.append(f"Embedding: {emb_msg}") - error_msg = "; ".join(errors) - return convert_resp(code=400, status=400, message=error_msg) - - return convert_resp(code=0, status=200, message="连接测试成功!VLM和Embedding模型均正常") - - except Exception as e: - logger.exception(f"Validation failed: {e}") - return convert_resp(code=500, status=500, message=f"Validation failed: {str(e)}") - - -# ==================== General Settings ==================== - - -class GeneralSettingsRequest(BaseModel): - """General system settings request""" - - capture: dict | None = None - processing: dict | None = None - logging: dict | None = None - content_generation: dict | None = None - - -@router.get("/api/settings/general") -async def get_general_settings(_auth: str = auth_dependency): - """Get general system settings""" - try: - import os - - config = GlobalConfig.get_instance().get_config() - if not config: - return convert_resp(code=500, status=500, message="Configuration not initialized") - - settings = { - "capture": config.get("capture", {}), - "processing": config.get("processing", {}), - "logging": config.get("logging", {}), - "content_generation": config.get("content_generation", {}), - } - - # Resolve environment variables in debug output path for display - if "content_generation" in settings and "debug" in settings["content_generation"]: - debug_config = settings["content_generation"]["debug"] - if "output_path" in debug_config: - output_path = debug_config["output_path"] - # Resolve environment variables - if "${CONTEXT_PATH" in output_path: - context_path = os.getenv("CONTEXT_PATH", ".") - resolved_path = output_path.replace("${CONTEXT_PATH:.}", context_path) - resolved_path = resolved_path.replace("${CONTEXT_PATH}", context_path) - # Add resolved path as a separate field for display - debug_config["output_path_resolved"] = resolved_path - - return convert_resp(data=settings) - - except Exception as e: - logger.exception(f"Failed to get general settings: {e}") - return convert_resp( - code=500, status=500, message=f"Failed to get general settings: {str(e)}" - ) - - -@router.post("/api/settings/general") -async def update_general_settings(request: GeneralSettingsRequest, _auth: str = auth_dependency): - """Update general system settings""" - with _config_lock: - try: - config_mgr = GlobalConfig.get_instance().get_config_manager() - if not config_mgr: - return convert_resp(code=500, status=500, message="Config manager not initialized") - - # Build settings dict - settings = {} - if request.capture is not None: - settings["capture"] = request.capture - if request.processing is not None: - settings["processing"] = request.processing - if request.logging is not None: - settings["logging"] = request.logging - if request.content_generation is not None: - settings["content_generation"] = request.content_generation - - if not settings: - return convert_resp(code=400, status=400, message="No settings provided") - - # Save to user_setting.yaml - if not config_mgr.save_user_settings(settings): - return convert_resp(code=500, status=500, message="Failed to save settings") - - # Reload config - config_mgr.load_config(config_mgr.get_config_path()) - - logger.info("General settings updated successfully") - return convert_resp(code=0, status=200, message="Settings updated successfully") - - except Exception as e: - logger.exception(f"Failed to update general settings: {e}") - return convert_resp( - code=500, status=500, message=f"Failed to update settings: {str(e)}" - ) - - -# ==================== Prompts Settings ==================== - - -class PromptsUpdateRequest(BaseModel): - """Prompts update request""" - - prompts: dict - - -@router.get("/api/settings/prompts") -async def get_prompts(_auth: str = auth_dependency): - """Get current prompts""" - try: - prompt_mgr = GlobalConfig.get_instance().get_prompt_manager() - if not prompt_mgr: - return convert_resp(code=500, status=500, message="Prompt manager not initialized") - - return convert_resp(data={"prompts": prompt_mgr.prompts}) - - except Exception as e: - logger.exception(f"Failed to get prompts: {e}") - return convert_resp(code=500, status=500, message=f"Failed to get prompts: {str(e)}") - - -@router.post("/api/settings/prompts") -async def update_prompts(request: PromptsUpdateRequest, _auth: str = auth_dependency): - """Update prompts""" - try: - prompt_mgr = GlobalConfig.get_instance().get_prompt_manager() - if not prompt_mgr: - return convert_resp(code=500, status=500, message="Prompt manager not initialized") - - if not prompt_mgr.save_prompts(request.prompts): - return convert_resp(code=500, status=500, message="Failed to save prompts") - - logger.info("Prompts updated successfully") - return convert_resp(code=0, status=200, message="Prompts updated successfully") - - except Exception as e: - logger.exception(f"Failed to update prompts: {e}") - return convert_resp(code=500, status=500, message=f"Failed to update prompts: {str(e)}") - - -@router.post("/api/settings/prompts/import") -async def import_prompts(file: UploadFile = File(...), _auth: str = auth_dependency): - """Import prompts from YAML file""" - try: - prompt_mgr = GlobalConfig.get_instance().get_prompt_manager() - if not prompt_mgr: - return convert_resp(code=500, status=500, message="Prompt manager not initialized") - - # Read file content - content = await file.read() - yaml_content = content.decode("utf-8") - - if not prompt_mgr.import_prompts(yaml_content): - return convert_resp(code=400, status=400, message="Failed to import prompts") - - logger.info("Prompts imported successfully") - return convert_resp(code=0, status=200, message="Prompts imported successfully") - - except Exception as e: - logger.exception(f"Failed to import prompts: {e}") - return convert_resp(code=500, status=500, message=f"Failed to import prompts: {str(e)}") - - -@router.get("/api/settings/prompts/export") -async def export_prompts(_auth: str = auth_dependency): - """Export prompts as YAML file""" - try: - prompt_mgr = GlobalConfig.get_instance().get_prompt_manager() - if not prompt_mgr: - return convert_resp(code=500, status=500, message="Prompt manager not initialized") - - yaml_content = prompt_mgr.export_prompts() - if not yaml_content: - return convert_resp(code=500, status=500, message="Failed to export prompts") - - # Return as downloadable file - language = GlobalConfig.get_instance().get_language() - filename = f"prompts_{language}.yaml" - - return StreamingResponse( - io.BytesIO(yaml_content.encode("utf-8")), - media_type="application/x-yaml", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - except Exception as e: - logger.exception(f"Failed to export prompts: {e}") - return convert_resp(code=500, status=500, message=f"Failed to export prompts: {str(e)}") - - -@router.get("/api/settings/prompts/language") -async def get_prompt_language(_auth: str = auth_dependency): - """Get current prompt language""" - try: - language = GlobalConfig.get_instance().get_language() - return convert_resp(data={"language": language}) - except Exception as e: - logger.exception(f"Failed to get prompt language: {e}") - return convert_resp(code=500, status=500, message=f"Failed to get language: {str(e)}") - - -class LanguageChangeRequest(BaseModel): - """Language change request""" - - language: str = Field(..., pattern="^(zh|en)$") - - -@router.post("/api/settings/prompts/language") -async def change_prompt_language(request: LanguageChangeRequest, _auth: str = auth_dependency): - """Change prompt language""" - try: - # Update language setting and reload prompts - success = GlobalConfig.get_instance().set_language(request.language) - - if not success: - return convert_resp(code=500, status=500, message="Failed to change language") - - logger.info(f"Prompt language changed to: {request.language}") - return convert_resp(message=f"Language changed to {request.language}") - except Exception as e: - logger.exception(f"Failed to change prompt language: {e}") - return convert_resp(code=500, status=500, message=f"Failed to change language: {str(e)}") - - -# ==================== Reset Settings ==================== - - -@router.post("/api/settings/reset") -async def reset_settings(_auth: str = auth_dependency): - """Reset all user settings to defaults""" - with _config_lock: - try: - config_mgr = GlobalConfig.get_instance().get_config_manager() - prompt_mgr = GlobalConfig.get_instance().get_prompt_manager() - - success = True - - # Reset user settings - if config_mgr: - if not config_mgr.reset_user_settings(): - success = False - logger.error("Failed to reset user settings") - - # Reset user prompts - if prompt_mgr: - if not prompt_mgr.reset_user_prompts(): - success = False - logger.error("Failed to reset user prompts") - - if not success: - return convert_resp(code=500, status=500, message="Failed to reset some settings") - - logger.info("All settings reset successfully") - return convert_resp(code=0, status=200, message="Settings reset successfully") - - except Exception as e: - logger.exception(f"Failed to reset settings: {e}") - return convert_resp(code=500, status=500, message=f"Failed to reset settings: {str(e)}") - - -# ==================== Prompts History & Debug ==================== - - -@router.get("/api/settings/prompts/history/{category}") -async def get_prompts_history(category: str, _auth: str = auth_dependency): - """ - Get debug history files for a prompt category - - Args: - category: Prompt category (smart_tip_generation, todo_extraction, generation_report, realtime_activity_monitor) - - Returns: - List of history files with metadata - """ - try: - import os - from pathlib import Path - - # Map category names to directory names - category_map = { - "smart_tip_generation": "tips", - "todo_extraction": "todo", - "generation_report": "report", - "realtime_activity_monitor": "activity", - } - - if category not in category_map: - return convert_resp(code=400, status=400, message=f"Invalid category: {category}") - - dir_name = category_map[category] - - # Get debug output path from config - config = GlobalConfig.get_instance().get_config() - if not config: - return convert_resp(code=500, status=500, message="Configuration not initialized") - - debug_config = config.get("content_generation", {}).get("debug", {}) - if not debug_config.get("enabled", False): - return convert_resp(code=400, status=400, message="Debug mode is not enabled") - - base_path = debug_config.get("output_path", "${CONTEXT_PATH:.}/debug/generation") - - # Resolve environment variables - if "${CONTEXT_PATH" in base_path: - context_path = os.getenv("CONTEXT_PATH", ".") - base_path = base_path.replace("${CONTEXT_PATH:.}", context_path) - base_path = base_path.replace("${CONTEXT_PATH}", context_path) - - # Get directory path - history_dir = Path(base_path) / dir_name - - if not history_dir.exists(): - return convert_resp(data=[]) - - # List all JSON files - history_files = [] - for filepath in sorted(history_dir.glob("*.json"), reverse=True): # Most recent first - try: - # Read file to check if it has response - import json - - with open(filepath, "r", encoding="utf-8") as f: - data = json.load(f) - - has_result = bool(data.get("response")) - timestamp_str = data.get("timestamp", "") - - history_files.append( - { - "filename": filepath.name, - "timestamp": timestamp_str, - "has_result": has_result, - } - ) - except Exception as e: - logger.warning(f"Failed to read history file {filepath}: {e}") - continue - - return convert_resp(data=history_files) - - except Exception as e: - logger.exception(f"Failed to get prompts history for {category}: {e}") - return convert_resp(code=500, status=500, message=f"Failed to get history: {str(e)}") - - -@router.get("/api/settings/prompts/history/{category}/{filename}") -async def get_prompts_history_detail(category: str, filename: str, _auth: str = auth_dependency): - """ - Get detailed content of a specific debug history file - - Args: - category: Prompt category - filename: History file name - - Returns: - Debug file content with messages and response - """ - try: - import json - import os - from pathlib import Path - - # Map category names to directory names - category_map = { - "smart_tip_generation": "tips", - "todo_extraction": "todo", - "generation_report": "report", - "realtime_activity_monitor": "activity", - } - - if category not in category_map: - return convert_resp(code=400, status=400, message=f"Invalid category: {category}") - - dir_name = category_map[category] - - # Get debug output path from config - config = GlobalConfig.get_instance().get_config() - if not config: - return convert_resp(code=500, status=500, message="Configuration not initialized") - - debug_config = config.get("content_generation", {}).get("debug", {}) - base_path = debug_config.get("output_path", "${CONTEXT_PATH:.}/debug/generation") - - # Resolve environment variables - if "${CONTEXT_PATH" in base_path: - context_path = os.getenv("CONTEXT_PATH", ".") - base_path = base_path.replace("${CONTEXT_PATH:.}", context_path) - base_path = base_path.replace("${CONTEXT_PATH}", context_path) - - # Get file path (validate filename to prevent directory traversal) - if ".." in filename or "/" in filename or "\\" in filename: - return convert_resp(code=400, status=400, message="Invalid filename") - - filepath = Path(base_path) / dir_name / filename - - if not filepath.exists(): - return convert_resp(code=404, status=404, message="History file not found") - - # Read and return file content - with open(filepath, "r", encoding="utf-8") as f: - data = json.load(f) - - return convert_resp(data=data) - - except Exception as e: - logger.exception(f"Failed to get history detail for {category}/{filename}: {e}") - return convert_resp(code=500, status=500, message=f"Failed to get history detail: {str(e)}") - - -class RegenerateRequest(BaseModel): - """Regenerate request with custom prompts""" - - category: str - history_file: str - custom_prompts: dict = Field( - default_factory=dict, description="Custom prompts with system and user keys" - ) - - -@router.post("/api/settings/prompts/regenerate") -async def regenerate_with_custom_prompts(request: RegenerateRequest, _auth: str = auth_dependency): - """ - Regenerate content using custom prompts and compare with original result - - Args: - request: Regenerate request with category, history_file, and custom_prompts - - Returns: - Comparison data with original and new results - """ - try: - import json - import os - from pathlib import Path - - # Map category names to directory names - category_map = { - "smart_tip_generation": "tips", - "todo_extraction": "todo", - "generation_report": "report", - "realtime_activity_monitor": "activity", - } - - if request.category not in category_map: - return convert_resp( - code=400, status=400, message=f"Invalid category: {request.category}" - ) - - dir_name = category_map[request.category] - - # Get debug output path from config - config = GlobalConfig.get_instance().get_config() - if not config: - return convert_resp(code=500, status=500, message="Configuration not initialized") - - debug_config = config.get("content_generation", {}).get("debug", {}) - base_path = debug_config.get("output_path", "${CONTEXT_PATH:.}/debug/generation") - - # Resolve environment variables - if "${CONTEXT_PATH" in base_path: - context_path = os.getenv("CONTEXT_PATH", ".") - base_path = base_path.replace("${CONTEXT_PATH:.}", context_path) - base_path = base_path.replace("${CONTEXT_PATH}", context_path) - - # Validate and read history file - if ( - ".." in request.history_file - or "/" in request.history_file - or "\\" in request.history_file - ): - return convert_resp(code=400, status=400, message="Invalid filename") - - filepath = Path(base_path) / dir_name / request.history_file - - if not filepath.exists(): - return convert_resp(code=404, status=404, message="History file not found") - - # Read original debug data - with open(filepath, "r", encoding="utf-8") as f: - original_data = json.load(f) - - original_messages = original_data.get("messages", []) - original_response = original_data.get("response", "") - - if not original_messages: - return convert_resp(code=400, status=400, message="No messages found in history file") - - # Replace prompts with custom ones - new_messages = [] - for msg in original_messages: - if msg.get("role") == "system" and request.custom_prompts.get("system"): - new_messages.append({"role": "system", "content": request.custom_prompts["system"]}) - elif msg.get("role") == "user" and request.custom_prompts.get("user"): - # For user messages, we might want to keep the data but replace the template - # This is tricky as user messages contain actual data, not just template - # For now, we'll keep the original user message as it contains real data - new_messages.append(msg) - else: - new_messages.append(msg) - - # Generate new response - from opencontext.llm.global_vlm_client import generate_with_messages - - response = generate_with_messages(new_messages) - # Return comparison data - comparison_data = { - "original_result": original_response, - "new_result": response, - "custom_prompts": request.custom_prompts, - "timestamp": original_data.get("timestamp", ""), - "category": request.category, - } - - return convert_resp(data=comparison_data) - - except Exception as e: - logger.exception(f"Failed to regenerate with custom prompts: {e}") - return convert_resp(code=500, status=500, message=f"Failed to regenerate: {str(e)}")