Merge branch 'main' of github.com:CherryHQ/cherry-studio into v2

This commit is contained in:
fullex 2025-10-21 10:09:13 +08:00
commit e59990d24e
35 changed files with 1899 additions and 1143 deletions

View File

@ -1,252 +0,0 @@
default-mode:
add:
remove: [pull_request_target, issues]
labels:
# <!-- [Ss]kip `LABEL` --> 跳过一个 label
# <!-- [Rr]emove `LABEL` --> 去掉一个 label
# skips and removes
- name: skip all
content:
regexes: '[Ss]kip (?:[Aa]ll |)[Ll]abels?'
- name: remove all
content:
regexes: '[Rr]emove (?:[Aa]ll |)[Ll]abels?'
- name: skip kind/bug
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: remove kind/bug
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/bug(?:`|)'
- name: skip kind/enhancement
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: remove kind/enhancement
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/enhancement(?:`|)'
- name: skip kind/question
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: remove kind/question
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/question(?:`|)'
- name: skip area/Connectivity
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: remove area/Connectivity
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/Connectivity(?:`|)'
- name: skip area/UI/UX
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: remove area/UI/UX
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)area/UI/UX(?:`|)'
- name: skip kind/documentation
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: remove kind/documentation
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)kind/documentation(?:`|)'
- name: skip client:linux
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: remove client:linux
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:linux(?:`|)'
- name: skip client:mac
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: remove client:mac
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:mac(?:`|)'
- name: skip client:win
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: remove client:win
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)client:win(?:`|)'
- name: skip sig/Assistant
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: remove sig/Assistant
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Assistant(?:`|)'
- name: skip sig/Data
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: remove sig/Data
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/Data(?:`|)'
- name: skip sig/MCP
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: remove sig/MCP
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/MCP(?:`|)'
- name: skip sig/RAG
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: remove sig/RAG
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)sig/RAG(?:`|)'
- name: skip lgtm
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: remove lgtm
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)lgtm(?:`|)'
- name: skip License
content:
regexes: '[Ss]kip (?:[Ll]abels? |)(?:`|)License(?:`|)'
- name: remove License
content:
regexes: '[Rr]emove (?:[Ll]abels? |)(?:`|)License(?:`|)'
# `Dev Team`
- name: Dev Team
mode:
add: [pull_request_target, issues]
author_association:
- COLLABORATOR
# Area labels
- name: area/Connectivity
content: area/Connectivity
regexes: '代理|[Pp]roxy'
skip-if:
- skip all
- skip area/Connectivity
remove-if:
- remove all
- remove area/Connectivity
- name: area/UI/UX
content: area/UI/UX
regexes: '界面|[Uu][Ii]|重叠|按钮|图标|组件|渲染|菜单|栏目|头像|主题|样式|[Cc][Ss][Ss]'
skip-if:
- skip all
- skip area/UI/UX
remove-if:
- remove all
- remove area/UI/UX
# Kind labels
- name: kind/documentation
content: kind/documentation
regexes: '文档|教程|[Dd]oc(s|umentation)|[Rr]eadme'
skip-if:
- skip all
- skip kind/documentation
remove-if:
- remove all
- remove kind/documentation
# Client labels
- name: client:linux
content: client:linux
regexes: '(?:[Ll]inux|[Uu]buntu|[Dd]ebian)'
skip-if:
- skip all
- skip client:linux
remove-if:
- remove all
- remove client:linux
- name: client:mac
content: client:mac
regexes: '(?:[Mm]ac|[Mm]acOS|[Oo]SX)'
skip-if:
- skip all
- skip client:mac
remove-if:
- remove all
- remove client:mac
- name: client:win
content: client:win
regexes: '(?:[Ww]in|[Ww]indows)'
skip-if:
- skip all
- skip client:win
remove-if:
- remove all
- remove client:win
# SIG labels
- name: sig/Assistant
content: sig/Assistant
regexes: '快捷助手|[Aa]ssistant'
skip-if:
- skip all
- skip sig/Assistant
remove-if:
- remove all
- remove sig/Assistant
- name: sig/Data
content: sig/Data
regexes: '[Ww]ebdav|坚果云|备份|同步|数据|Obsidian|Notion|Joplin|思源'
skip-if:
- skip all
- skip sig/Data
remove-if:
- remove all
- remove sig/Data
- name: sig/MCP
content: sig/MCP
regexes: '[Mm][Cc][Pp]'
skip-if:
- skip all
- skip sig/MCP
remove-if:
- remove all
- remove sig/MCP
- name: sig/RAG
content: sig/RAG
regexes: '知识库|[Rr][Aa][Gg]'
skip-if:
- skip all
- skip sig/RAG
remove-if:
- remove all
- remove sig/RAG
# Other labels
- name: lgtm
content: lgtm
regexes: '(?:[Ll][Gg][Tt][Mm]|[Ll]ooks [Gg]ood [Tt]o [Mm]e)'
skip-if:
- skip all
- skip lgtm
remove-if:
- remove all
- remove lgtm
- name: License
content: License
regexes: '(?:[Ll]icense|[Cc]opyright|[Mm][Ii][Tt]|[Aa]pache)'
skip-if:
- skip all
- skip License
remove-if:
- remove all
- remove License

View File

@ -13,6 +13,7 @@ jobs:
steps: steps:
- name: Delete merged branch - name: Delete merged branch
uses: actions/github-script@v8 uses: actions/github-script@v8
continue-on-error: true
with: with:
script: | script: |
github.rest.git.deleteRef({ github.rest.git.deleteRef({

View File

@ -1,25 +0,0 @@
name: 'Issue Checker'
on:
issues:
types: [opened, edited]
pull_request_target:
types: [opened, edited]
issue_comment:
types: [created, edited]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: MaaAssistantArknights/issue-checker@v1.14
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
configuration-path: .github/issue-checker.yml
not-before: 2022-08-05T00:00:00Z
include-title: 1

View File

@ -1,44 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index 53f411e55a4c9a06fd29bb4ab8161c4ad15980cd..71b91f196c8b886ed90dd237dec5625d79d5677e 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -12676,10 +12676,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];
diff --git a/dist/index.mjs b/dist/index.mjs
index 7719264da3c49a66c2626082f6ccaae6e3ef5e89..090fd8cf142674192a826148428ed6a0c4a54e35 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -12670,10 +12670,13 @@ var OpenAIResponsesLanguageModel = class {
}
});
} else if (value.item.type === "message") {
- controller.enqueue({
- type: "text-end",
- id: value.item.id
- });
+ // Fix for gpt-5-codex: use currentTextId to ensure text-end matches text-start
+ if (currentTextId) {
+ controller.enqueue({
+ type: "text-end",
+ id: currentTextId
+ });
+ }
currentTextId = null;
} else if (isResponseOutputItemDoneReasoningChunk(value)) {
const activeReasoningPart = activeReasoning[value.item.id];

View File

@ -157,7 +157,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0",
"@opeoginni/github-copilot-openai-compatible": "patch:@opeoginni/github-copilot-openai-compatible@npm%3A0.1.18#~/.yarn/patches/@opeoginni-github-copilot-openai-compatible-npm-0.1.18-3f65760532.patch", "@opeoginni/github-copilot-openai-compatible": "0.1.19",
"@playwright/test": "^1.52.0", "@playwright/test": "^1.52.0",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "^2.2.16",
"@reduxjs/toolkit": "^2.2.5", "@reduxjs/toolkit": "^2.2.5",
@ -387,7 +387,13 @@
"undici": "6.21.2", "undici": "6.21.2",
"vite": "npm:rolldown-vite@latest", "vite": "npm:rolldown-vite@latest",
"tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch", "tesseract.js@npm:*": "patch:tesseract.js@npm%3A6.0.1#~/.yarn/patches/tesseract.js-npm-6.0.1-2562a7e46d.patch",
"@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch" "@ai-sdk/google@npm:2.0.20": "patch:@ai-sdk/google@npm%3A2.0.20#~/.yarn/patches/@ai-sdk-google-npm-2.0.20-b9102f9d54.patch",
"@img/sharp-darwin-arm64": "0.34.3",
"@img/sharp-darwin-x64": "0.34.3",
"@img/sharp-linux-arm": "0.34.3",
"@img/sharp-linux-arm64": "0.34.3",
"@img/sharp-linux-x64": "0.34.3",
"@img/sharp-win32-x64": "0.34.3"
}, },
"packageManager": "yarn@4.9.1", "packageManager": "yarn@4.9.1",
"lint-staged": { "lint-staged": {

View File

@ -264,7 +264,7 @@ describe('file', () => {
const buffer = iconv.encode(content, 'GB18030') const buffer = iconv.encode(content, 'GB18030')
// 模拟文件读取和编码检测 // 模拟文件读取和编码检测
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string)
vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030')
const result = await readTextFileWithAutoEncoding(mockFilePath) const result = await readTextFileWithAutoEncoding(mockFilePath)
@ -276,7 +276,7 @@ describe('file', () => {
const buffer = iconv.encode(content, 'UTF-8') const buffer = iconv.encode(content, 'UTF-8')
// 模拟文件读取 // 模拟文件读取
vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer) vi.spyOn(fsPromises, 'readFile').mockResolvedValue(buffer as unknown as string)
vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030') vi.spyOn(chardet, 'detectFile').mockResolvedValue('GB18030')
const result = await readTextFileWithAutoEncoding(mockFilePath) const result = await readTextFileWithAutoEncoding(mockFilePath)

View File

@ -6,6 +6,7 @@ import type { LanguageModelMiddleware } from 'ai'
import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai' import { extractReasoningMiddleware, simulateStreamingMiddleware } from 'ai'
import { noThinkMiddleware } from './noThinkMiddleware' import { noThinkMiddleware } from './noThinkMiddleware'
import { toolChoiceMiddleware } from './toolChoiceMiddleware'
const logger = loggerService.withContext('AiSdkMiddlewareBuilder') const logger = loggerService.withContext('AiSdkMiddlewareBuilder')
@ -32,6 +33,8 @@ export interface AiSdkMiddlewareConfig {
uiMessages?: Message[] uiMessages?: Message[]
// 内置搜索配置 // 内置搜索配置
webSearchPluginConfig?: WebSearchPluginConfig webSearchPluginConfig?: WebSearchPluginConfig
// 知识库识别开关,默认开启
knowledgeRecognition?: 'off' | 'on'
} }
/** /**
@ -122,6 +125,15 @@ export class AiSdkMiddlewareBuilder {
export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageModelMiddleware[] { export function buildAiSdkMiddlewares(config: AiSdkMiddlewareConfig): LanguageModelMiddleware[] {
const builder = new AiSdkMiddlewareBuilder() const builder = new AiSdkMiddlewareBuilder()
// 0. 知识库强制调用中间件(必须在最前面,确保第一轮强制调用知识库)
if (config.knowledgeRecognition === 'off') {
builder.add({
name: 'force-knowledge-first',
middleware: toolChoiceMiddleware('builtin_knowledge_search')
})
logger.debug('Added toolChoice middleware to force knowledge base search on first round')
}
// 1. 根据provider添加特定中间件 // 1. 根据provider添加特定中间件
if (config.provider) { if (config.provider) {
addProviderSpecificMiddlewares(builder, config) addProviderSpecificMiddlewares(builder, config)

View File

@ -0,0 +1,45 @@
import { loggerService } from '@logger'
import type { LanguageModelMiddleware } from 'ai'
const logger = loggerService.withContext('toolChoiceMiddleware')
/**
* Tool Choice Middleware
* Controls tool selection strategy across multiple rounds of tool calls:
* - First round: Forces the model to call a specific tool (e.g., knowledge base search)
* - Subsequent rounds: Allows the model to automatically choose any available tool
*
* This ensures knowledge base is consulted first while still enabling MCP tools
* and other capabilities in follow-up interactions.
*
* @param forceFirstToolName - The tool name to force on the first round
* @returns LanguageModelMiddleware
*/
export function toolChoiceMiddleware(forceFirstToolName: string): LanguageModelMiddleware {
let toolCallRound = 0
return {
middlewareVersion: 'v2',
transformParams: async ({ params }) => {
toolCallRound++
const transformedParams = { ...params }
if (toolCallRound === 1) {
// First round: force the specified tool
logger.debug(`Round ${toolCallRound}: Forcing tool choice to '${forceFirstToolName}'`)
transformedParams.toolChoice = {
type: 'tool',
toolName: forceFirstToolName
}
} else {
// Subsequent rounds: allow automatic tool selection
logger.debug(`Round ${toolCallRound}: Using automatic tool choice`)
transformedParams.toolChoice = { type: 'auto' }
}
return transformedParams
}
}
}

View File

@ -5,6 +5,7 @@ import {
GEMINI_FLASH_MODEL_REGEX, GEMINI_FLASH_MODEL_REGEX,
getThinkModelType, getThinkModelType,
isDeepSeekHybridInferenceModel, isDeepSeekHybridInferenceModel,
isDoubaoSeedAfter251015,
isDoubaoThinkingAutoModel, isDoubaoThinkingAutoModel,
isGrok4FastReasoningModel, isGrok4FastReasoningModel,
isGrokReasoningModel, isGrokReasoningModel,
@ -171,6 +172,10 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
// Doubao 思考模式支持 // Doubao 思考模式支持
if (isSupportedThinkingTokenDoubaoModel(model)) { if (isSupportedThinkingTokenDoubaoModel(model)) {
if (isDoubaoSeedAfter251015(model)) {
return { reasoningEffort }
}
// Comment below this line seems weird. reasoning is high instead of null/undefined. Who wrote this?
// reasoningEffort 为空,默认开启 enabled // reasoningEffort 为空,默认开启 enabled
if (reasoningEffort === 'high') { if (reasoningEffort === 'high') {
return { thinking: { type: 'enabled' } } return { thinking: { type: 'enabled' } }
@ -227,12 +232,12 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType] const supportedOptions = MODEL_SUPPORTED_REASONING_EFFORT[modelType]
if (supportedOptions.includes(reasoningEffort)) { if (supportedOptions.includes(reasoningEffort)) {
return { return {
reasoning_effort: reasoningEffort reasoningEffort
} }
} else { } else {
// 如果不支持fallback到第一个支持的值 // 如果不支持fallback到第一个支持的值
return { return {
reasoning_effort: supportedOptions[0] reasoningEffort: supportedOptions[0]
} }
} }
} }

View File

@ -0,0 +1,45 @@
import { Button } from '@heroui/react'
import { CheckIcon, XIcon } from 'lucide-react'
import type { FC } from 'react'
import { createPortal } from 'react-dom'
interface Props {
x: number
y: number
message: string
onConfirm: () => void
onCancel: () => void
}
const ConfirmDialog: FC<Props> = ({ x, y, message, onConfirm, onCancel }) => {
if (typeof document === 'undefined') {
return null
}
return createPortal(
<>
<div className="fixed inset-0 z-[99998] bg-transparent" onClick={onCancel} />
<div
className="-translate-x-1/2 -translate-y-full fixed z-[99999] mt-[-8px] transform"
style={{
left: `${x}px`,
top: `${y}px`
}}>
<div className="flex min-w-[160px] items-center rounded-lg border border-[var(--color-border)] bg-[var(--color-background)] p-3 shadow-[0_4px_12px_rgba(0,0,0,0.15)]">
<div className="mr-2 text-sm leading-[1.4]">{message}</div>
<div className="flex justify-center gap-2">
<Button onPress={onCancel} radius="full" className="h-6 w-6 min-w-0 p-1" color="danger">
<XIcon className="text-danger-foreground" size={16} />
</Button>
<Button onPress={onConfirm} radius="full" className="h-6 w-6 min-w-0 p-1" color="success">
<CheckIcon className="text-success-foreground" size={16} />
</Button>
</div>
</div>
</div>
</>,
document.body
)
}
export default ConfirmDialog

View File

@ -14,6 +14,7 @@ export interface CustomTagProps {
closable?: boolean closable?: boolean
onClose?: () => void onClose?: () => void
onClick?: MouseEventHandler<HTMLDivElement> onClick?: MouseEventHandler<HTMLDivElement>
onContextMenu?: MouseEventHandler<HTMLDivElement>
disabled?: boolean disabled?: boolean
inactive?: boolean inactive?: boolean
} }
@ -28,6 +29,7 @@ const CustomTag: FC<CustomTagProps> = ({
closable = false, closable = false,
onClose, onClose,
onClick, onClick,
onContextMenu,
disabled, disabled,
inactive inactive
}) => { }) => {
@ -40,6 +42,7 @@ const CustomTag: FC<CustomTagProps> = ({
$closable={closable} $closable={closable}
$clickable={!disabled && !!onClick} $clickable={!disabled && !!onClick}
onClick={disabled ? undefined : onClick} onClick={disabled ? undefined : onClick}
onContextMenu={disabled ? undefined : onContextMenu}
style={{ style={{
...(disabled && { cursor: 'not-allowed' }), ...(disabled && { cursor: 'not-allowed' }),
...style ...style
@ -57,7 +60,7 @@ const CustomTag: FC<CustomTagProps> = ({
)} )}
</Tag> </Tag>
), ),
[actualColor, children, closable, disabled, icon, onClick, onClose, size, style] [actualColor, children, closable, disabled, icon, onClick, onClose, onContextMenu, size, style]
) )
return tooltip ? ( return tooltip ? (

View File

@ -0,0 +1,166 @@
import { describe, expect, it, vi } from 'vitest'
import { isDoubaoSeedAfter251015, isDoubaoThinkingAutoModel } from '../models/reasoning'
// FIXME: Idk why it's imported. Maybe circular dependency somewhere
vi.mock('@renderer/services/AssistantService.ts', () => ({
getDefaultAssistant: () => {
return {
id: 'default',
name: 'default',
emoji: '😀',
prompt: '',
topics: [],
messages: [],
type: 'assistant',
regularPhrases: [],
settings: {}
}
}
}))
describe('Doubao Models', () => {
describe('isDoubaoThinkingAutoModel', () => {
it('should return false for invalid models', () => {
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-251015',
name: 'doubao-seed-1-6-251015',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-lite-251015',
name: 'doubao-seed-1-6-lite-251015',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-thinking-250715',
name: 'doubao-seed-1-6-thinking-250715',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-flash',
name: 'doubao-seed-1-6-flash',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-thinking',
name: 'doubao-seed-1-6-thinking',
provider: '',
group: ''
})
).toBe(false)
})
it('should return true for valid models', () => {
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1-6-250615',
name: 'doubao-seed-1-6-250615',
provider: '',
group: ''
})
).toBe(true)
expect(
isDoubaoThinkingAutoModel({
id: 'Doubao-Seed-1.6',
name: 'Doubao-Seed-1.6',
provider: '',
group: ''
})
).toBe(true)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-1-5-thinking-pro-m',
name: 'doubao-1-5-thinking-pro-m',
provider: '',
group: ''
})
).toBe(true)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-seed-1.6-lite',
name: 'doubao-seed-1.6-lite',
provider: '',
group: ''
})
).toBe(true)
expect(
isDoubaoThinkingAutoModel({
id: 'doubao-1-5-thinking-pro-m-12345',
name: 'doubao-1-5-thinking-pro-m-12345',
provider: '',
group: ''
})
).toBe(true)
})
})
describe('isDoubaoSeedAfter251015', () => {
it('should return true for models matching the pattern', () => {
expect(
isDoubaoSeedAfter251015({
id: 'doubao-seed-1-6-251015',
name: '',
provider: '',
group: ''
})
).toBe(true)
expect(
isDoubaoSeedAfter251015({
id: 'doubao-seed-1-6-lite-251015',
name: '',
provider: '',
group: ''
})
).toBe(true)
})
it('should return false for models not matching the pattern', () => {
expect(
isDoubaoSeedAfter251015({
id: 'doubao-seed-1-6-250615',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoSeedAfter251015({
id: 'Doubao-Seed-1.6',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoSeedAfter251015({
id: 'doubao-1-5-thinking-pro-m',
name: '',
provider: '',
group: ''
})
).toBe(false)
expect(
isDoubaoSeedAfter251015({
id: 'doubao-seed-1-6-lite-251016',
name: '',
provider: '',
group: ''
})
).toBe(false)
})
})
})

View File

@ -31,6 +31,7 @@ export const MODEL_SUPPORTED_REASONING_EFFORT: ReasoningEffortConfig = {
qwen_thinking: ['low', 'medium', 'high'] as const, qwen_thinking: ['low', 'medium', 'high'] as const,
doubao: ['auto', 'high'] as const, doubao: ['auto', 'high'] as const,
doubao_no_auto: ['high'] as const, doubao_no_auto: ['high'] as const,
doubao_after_251015: ['minimal', 'low', 'medium', 'high'] as const,
hunyuan: ['auto'] as const, hunyuan: ['auto'] as const,
zhipu: ['auto'] as const, zhipu: ['auto'] as const,
perplexity: ['low', 'medium', 'high'] as const, perplexity: ['low', 'medium', 'high'] as const,
@ -51,6 +52,7 @@ export const MODEL_SUPPORTED_OPTIONS: ThinkingOptionConfig = {
qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking, qwen_thinking: MODEL_SUPPORTED_REASONING_EFFORT.qwen_thinking,
doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const, doubao: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao] as const,
doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const, doubao_no_auto: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.doubao_no_auto] as const,
doubao_after_251015: MODEL_SUPPORTED_REASONING_EFFORT.doubao_after_251015,
hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const, hunyuan: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.hunyuan] as const,
zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const, zhipu: ['off', ...MODEL_SUPPORTED_REASONING_EFFORT.zhipu] as const,
perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity, perplexity: MODEL_SUPPORTED_REASONING_EFFORT.perplexity,
@ -85,6 +87,8 @@ export const getThinkModelType = (model: Model): ThinkingModelType => {
} else if (isSupportedThinkingTokenDoubaoModel(model)) { } else if (isSupportedThinkingTokenDoubaoModel(model)) {
if (isDoubaoThinkingAutoModel(model)) { if (isDoubaoThinkingAutoModel(model)) {
thinkingModelType = 'doubao' thinkingModelType = 'doubao'
} else if (isDoubaoSeedAfter251015(model)) {
thinkingModelType = 'doubao_after_251015'
} else { } else {
thinkingModelType = 'doubao_no_auto' thinkingModelType = 'doubao_no_auto'
} }
@ -308,14 +312,21 @@ export const DOUBAO_THINKING_MODEL_REGEX =
/doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i /doubao-(?:1[.-]5-thinking-vision-pro|1[.-]5-thinking-pro-m|seed-1[.-]6(?:-flash)?(?!-(?:thinking)(?:-|$)))(?:-[\w-]+)*/i
// 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx // 支持 auto 的 Doubao 模型 doubao-seed-1.6-xxx doubao-seed-1-6-xxx doubao-1-5-thinking-pro-m-xxx
// Auto thinking is no longer supported after version 251015, see https://console.volcengine.com/ark/region:ark+cn-beijing/model/detail?Id=doubao-seed-1-6
export const DOUBAO_THINKING_AUTO_MODEL_REGEX = export const DOUBAO_THINKING_AUTO_MODEL_REGEX =
/doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-[\w-]+)*/i /doubao-(1-5-thinking-pro-m|seed-1[.-]6)(?!-(?:flash|thinking)(?:-|$))(?:-lite)?(?!-251015)(?:-\d+)?$/i
export function isDoubaoThinkingAutoModel(model: Model): boolean { export function isDoubaoThinkingAutoModel(model: Model): boolean {
const modelId = getLowerBaseModelName(model.id) const modelId = getLowerBaseModelName(model.id)
return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name) return DOUBAO_THINKING_AUTO_MODEL_REGEX.test(modelId) || DOUBAO_THINKING_AUTO_MODEL_REGEX.test(model.name)
} }
export function isDoubaoSeedAfter251015(model: Model): boolean {
const pattern = new RegExp(/doubao-seed-1-6-(?:lite-)?251015/i)
const result = pattern.test(model.id)
return result
}
export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean { export function isSupportedThinkingTokenDoubaoModel(model?: Model): boolean {
if (!model) { if (!model) {
return false return false

View File

@ -14,6 +14,7 @@ export function usePaintings() {
const aihubmix_image_upscale = useAppSelector((state) => state.paintings.aihubmix_image_upscale) const aihubmix_image_upscale = useAppSelector((state) => state.paintings.aihubmix_image_upscale)
const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate) const openai_image_generate = useAppSelector((state) => state.paintings.openai_image_generate)
const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit) const openai_image_edit = useAppSelector((state) => state.paintings.openai_image_edit)
const ovms_paintings = useAppSelector((state) => state.paintings.ovms_paintings)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
return { return {
@ -27,6 +28,7 @@ export function usePaintings() {
aihubmix_image_upscale, aihubmix_image_upscale,
openai_image_generate, openai_image_generate,
openai_image_edit, openai_image_edit,
ovms_paintings,
addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => { addPainting: (namespace: keyof PaintingsState, painting: PaintingAction) => {
dispatch(addPainting({ namespace, painting })) dispatch(addPainting({ namespace, painting }))
return painting return painting

View File

@ -538,6 +538,7 @@
"context": "Clear Context {{Command}}" "context": "Clear Context {{Command}}"
}, },
"new_topic": "New Topic {{Command}}", "new_topic": "New Topic {{Command}}",
"paste_text_file_confirm": "Paste into input bar?",
"pause": "Pause", "pause": "Pause",
"placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools", "placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools",
"placeholder_without_triggers": "Type your message here, press {{key}} to send", "placeholder_without_triggers": "Type your message here, press {{key}} to send",

View File

@ -538,6 +538,7 @@
"context": "清除上下文 {{Command}}" "context": "清除上下文 {{Command}}"
}, },
"new_topic": "新话题 {{Command}}", "new_topic": "新话题 {{Command}}",
"paste_text_file_confirm": "粘贴到输入框?",
"pause": "暂停", "pause": "暂停",
"placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具",
"placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送",

View File

@ -538,6 +538,7 @@
"context": "清除上下文 {{Command}}" "context": "清除上下文 {{Command}}"
}, },
"new_topic": "新話題 {{Command}}", "new_topic": "新話題 {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "暫停", "pause": "暫停",
"placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具",
"placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送",

View File

@ -538,6 +538,7 @@
"context": "Καθαρισμός ενδιάμεσων {{Command}}" "context": "Καθαρισμός ενδιάμεσων {{Command}}"
}, },
"new_topic": "Νέο θέμα {{Command}}", "new_topic": "Νέο θέμα {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Παύση", "pause": "Παύση",
"placeholder": "Εισάγετε μήνυμα εδώ...", "placeholder": "Εισάγετε μήνυμα εδώ...",
"placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή", "placeholder_without_triggers": "Γράψτε το μήνυμά σας εδώ, πατήστε {{key}} για αποστολή",

View File

@ -538,6 +538,7 @@
"context": "Limpiar contexto {{Command}}" "context": "Limpiar contexto {{Command}}"
}, },
"new_topic": "Nuevo tema {{Command}}", "new_topic": "Nuevo tema {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Pausar", "pause": "Pausar",
"placeholder": "Escribe aquí tu mensaje...", "placeholder": "Escribe aquí tu mensaje...",
"placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar", "placeholder_without_triggers": "Escribe tu mensaje aquí, presiona {{key}} para enviar",

View File

@ -538,6 +538,7 @@
"context": "Effacer le contexte {{Command}}" "context": "Effacer le contexte {{Command}}"
}, },
"new_topic": "Nouveau sujet {{Command}}", "new_topic": "Nouveau sujet {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Pause", "pause": "Pause",
"placeholder": "Entrez votre message ici...", "placeholder": "Entrez votre message ici...",
"placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer", "placeholder_without_triggers": "Tapez votre message ici, appuyez sur {{key}} pour envoyer",

View File

@ -538,6 +538,7 @@
"context": "コンテキストをクリア {{Command}}" "context": "コンテキストをクリア {{Command}}"
}, },
"new_topic": "新しいトピック {{Command}}", "new_topic": "新しいトピック {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "一時停止", "pause": "一時停止",
"placeholder": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder": "ここにメッセージを入力し、{{key}} を押して送信...",
"placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...", "placeholder_without_triggers": "ここにメッセージを入力し、{{key}} を押して送信...",

View File

@ -538,6 +538,7 @@
"context": "Limpar contexto {{Command}}" "context": "Limpar contexto {{Command}}"
}, },
"new_topic": "Novo tópico {{Command}}", "new_topic": "Novo tópico {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Pausar", "pause": "Pausar",
"placeholder": "Digite sua mensagem aqui...", "placeholder": "Digite sua mensagem aqui...",
"placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar", "placeholder_without_triggers": "Escreve a tua mensagem aqui, pressiona {{key}} para enviar",

View File

@ -538,6 +538,7 @@
"context": "Очистить контекст {{Command}}" "context": "Очистить контекст {{Command}}"
}, },
"new_topic": "Новый топик {{Command}}", "new_topic": "Новый топик {{Command}}",
"paste_text_file_confirm": "[to be translated]:粘贴到输入框?",
"pause": "Остановить", "pause": "Остановить",
"placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...", "placeholder": "Введите ваше сообщение здесь, нажмите {{key}} для отправки...",
"placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки", "placeholder_without_triggers": "Напишите сообщение здесь, нажмите {{key}} для отправки",

View File

@ -12,8 +12,8 @@ import {
GlobalOutlined, GlobalOutlined,
LinkOutlined LinkOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { ColFlex } from '@cherrystudio/ui' import { ColFlex, Tooltip } from '@cherrystudio/ui'
import { Tooltip } from '@cherrystudio/ui' import ConfirmDialog from '@renderer/components/ConfirmDialog'
import CustomTag from '@renderer/components/Tags/CustomTag' import CustomTag from '@renderer/components/Tags/CustomTag'
import { useAttachment } from '@renderer/hooks/useAttachment' import { useAttachment } from '@renderer/hooks/useAttachment'
import FileManager from '@renderer/services/FileManager' import FileManager from '@renderer/services/FileManager'
@ -21,13 +21,15 @@ import type { FileMetadata } from '@renderer/types'
import { formatFileSize } from '@renderer/utils' import { formatFileSize } from '@renderer/utils'
import { Image } from 'antd' import { Image } from 'antd'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import type { FC } from 'react' import type { FC, MouseEvent } from 'react'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
files: FileMetadata[] files: FileMetadata[]
setFiles: (files: FileMetadata[]) => void setFiles: (files: FileMetadata[]) => void
onAttachmentContextMenu?: (file: FileMetadata, event: MouseEvent<HTMLDivElement>) => void
} }
const MAX_FILENAME_DISPLAY_LENGTH = 20 const MAX_FILENAME_DISPLAY_LENGTH = 20
@ -133,24 +135,91 @@ export const FileNameRender: FC<{ file: FileMetadata }> = ({ file }) => {
) )
} }
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => { const AttachmentPreview: FC<Props> = ({ files, setFiles, onAttachmentContextMenu }) => {
const { t } = useTranslation()
const [contextMenu, setContextMenu] = useState<{
file: FileMetadata
x: number
y: number
} | null>(null)
const handleContextMenu = async (file: FileMetadata, event: MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
// 获取被点击元素的位置
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
// 计算对话框位置:附件标签的中心位置
const x = rect.left + rect.width / 2
const y = rect.top
try {
const isText = await window.api.file.isTextFile(file.path)
if (!isText) {
setContextMenu(null)
return
}
setContextMenu({
file,
x,
y
})
} catch (error) {
setContextMenu(null)
}
}
const handleConfirm = () => {
if (contextMenu && onAttachmentContextMenu) {
// Create a synthetic mouse event for the callback
const syntheticEvent = {
preventDefault: () => {},
stopPropagation: () => {}
} as MouseEvent<HTMLDivElement>
onAttachmentContextMenu(contextMenu.file, syntheticEvent)
}
setContextMenu(null)
}
const handleCancel = () => {
setContextMenu(null)
}
if (isEmpty(files)) { if (isEmpty(files)) {
return null return null
} }
return ( return (
<ContentContainer> <>
{files.map((file) => ( <ContentContainer>
<CustomTag {files.map((file) => (
key={file.id} <CustomTag
icon={getFileIcon(file.ext)} key={file.id}
color="#37a5aa" icon={getFileIcon(file.ext)}
closable color="#37a5aa"
onClose={() => setFiles(files.filter((f) => f.id !== file.id))}> closable
<FileNameRender file={file} /> onClose={() => setFiles(files.filter((f) => f.id !== file.id))}
</CustomTag> onContextMenu={(event) => {
))} void handleContextMenu(file, event)
</ContentContainer> }}>
<FileNameRender file={file} />
</CustomTag>
))}
</ContentContainer>
{contextMenu && (
<ConfirmDialog
x={contextMenu.x}
y={contextMenu.y}
message={t('chat.input.paste_text_file_confirm')}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
</>
) )
} }

View File

@ -296,6 +296,53 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
}, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea]) }, [isTranslating, text, getLanguageByLangcode, targetLanguage, setTimeoutTimer, resizeTextArea])
const appendTxtContentToInput = useCallback(
async (file: FileType, event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
try {
const targetPath = file.path
const content = await window.api.file.readExternal(targetPath, true)
try {
await navigator.clipboard.writeText(content)
} catch (clipboardError) {
logger.warn('Failed to copy txt attachment content to clipboard:', clipboardError as Error)
}
setText((prev) => {
if (!prev) {
return content
}
const needsSeparator = !prev.endsWith('\n')
return needsSeparator ? `${prev}\n${content}` : prev + content
})
setFiles((prev) => prev.filter((currentFile) => currentFile.id !== file.id))
setTimeoutTimer(
'appendTxtAttachment',
() => {
const textArea = textareaRef.current?.resizableTextArea?.textArea
if (textArea) {
const end = textArea.value.length
textArea.focus()
textArea.setSelectionRange(end, end)
}
resizeTextArea(true)
},
0
)
} catch (error) {
logger.warn('Failed to append txt attachment content:', error as Error)
window.toast.error(t('chat.input.file_error'))
}
},
[resizeTextArea, setTimeoutTimer, t]
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
// 按下Tab键自动选中${xxx} // 按下Tab键自动选中${xxx}
if (event.key === 'Tab' && inputFocus) { if (event.key === 'Tab' && inputFocus) {
@ -834,7 +881,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
id="inputbar" id="inputbar"
className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')} className={classNames('inputbar-container', inputFocus && 'focus', isFileDragging && 'file-dragging')}
ref={containerRef}> ref={containerRef}>
{files.length > 0 && <AttachmentPreview files={files} setFiles={setFiles} />} {files.length > 0 && (
<AttachmentPreview files={files} setFiles={setFiles} onAttachmentContextMenu={appendTxtContentToInput} />
)}
{selectedKnowledgeBases.length > 0 && ( {selectedKnowledgeBases.length > 0 && (
<KnowledgeBaseInput <KnowledgeBaseInput
selectedKnowledgeBases={selectedKnowledgeBases} selectedKnowledgeBases={selectedKnowledgeBases}

View File

@ -1,5 +1,5 @@
import { usePreference } from '@data/hooks/usePreference' import { usePreference } from '@data/hooks/usePreference'
import { cn } from '@heroui/react' import { cn, Tooltip } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons' import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { useSessions } from '@renderer/hooks/agents/useSessions' import { useSessions } from '@renderer/hooks/agents/useSessions'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup' import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
@ -44,17 +44,13 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
<AgentNameWrapper> <AgentNameWrapper>
<AgentLabel agent={agent} /> <AgentLabel agent={agent} />
</AgentNameWrapper> </AgentNameWrapper>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
</MenuButton>
)}
{!isActive && <BotIcon />}
</AssistantNameRow> </AssistantNameRow>
{isActive && (
<MenuButton>
<SessionCount>{sessions.length}</SessionCount>
</MenuButton>
)}
{!isActive && (
<BotIcon>
<Bot size={16} className="text-primary" />
</BotIcon>
)}
</Container> </Container>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
@ -111,29 +107,27 @@ export const AgentNameWrapper: React.FC<React.HTMLAttributes<HTMLDivElement>> =
export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => ( export const MenuButton: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div <div
className={cn( className={cn(
'absolute top-[6px] right-[9px] flex h-[22px] min-h-[22px] w-[22px] flex-row items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-background)] px-[5px]', 'flex h-5 min-h-5 w-5 flex-row items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-background)]',
className className
)} )}
{...props} {...props}
/> />
) )
export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => ( export const BotIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ ...props }) => {
<div const { t } = useTranslation()
className={cn( return (
'absolute top-[8px] right-[12px] flex flex-row items-center justify-center rounded-full text-[14px] text-[var(--color-text)]', <Tooltip content={t('common.agent_one')} delay={500} closeDelay={0}>
className <MenuButton {...props}>
)} <Bot size={14} className="text-primary" />
{...props} </MenuButton>
/> </Tooltip>
) )
}
export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => ( export const SessionCount: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div <div
className={cn( className={cn('flex flex-row items-center justify-center rounded-full text-[var(--color-text)] text-xs', className)}
'flex flex-row items-center justify-center rounded-full text-[10px] text-[var(--color-text)]',
className
)}
{...props} {...props}
/> />
) )

View File

@ -0,0 +1,690 @@
import { PlusOutlined, RedoOutlined } from '@ant-design/icons'
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
import { useCache } from '@data/hooks/useCache'
import { loggerService } from '@logger'
import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import { isMac } from '@renderer/config/constant'
import { getProviderLogo } from '@renderer/config/providers'
import { LanguagesEnum } from '@renderer/config/translate'
import { usePaintings } from '@renderer/hooks/usePaintings'
import { useAllProviders } from '@renderer/hooks/useProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { getProviderLabel } from '@renderer/i18n/label'
import FileManager from '@renderer/services/FileManager'
import { translateText } from '@renderer/services/TranslateService'
import type { FileMetadata, OvmsPainting } from '@renderer/types'
import { getErrorMessage, uuid } from '@renderer/utils'
import { Avatar, Input, InputNumber, Select, Slider } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { Info } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import SendMessageButton from '../home/Inputbar/SendMessageButton'
import { SettingHelpLink, SettingTitle } from '../settings'
import Artboard from './components/Artboard'
import PaintingsList from './components/PaintingsList'
import {
type ConfigItem,
createDefaultOvmsPainting,
createOvmsConfig,
DEFAULT_OVMS_PAINTING,
getOvmsModels,
OVMS_MODELS
} from './config/ovmsConfig'
const logger = loggerService.withContext('OvmsPage')
const OvmsPage: FC<{ Options: string[] }> = ({ Options }) => {
const { addPainting, removePainting, updatePainting, ovms_paintings } = usePaintings()
const ovmsPaintings = useMemo(() => ovms_paintings || [], [ovms_paintings])
const [painting, setPainting] = useState<OvmsPainting>(ovmsPaintings[0] || DEFAULT_OVMS_PAINTING)
const [currentImageIndex, setCurrentImageIndex] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const [spaceClickCount, setSpaceClickCount] = useState(0)
const [isTranslating, setIsTranslating] = useState(false)
const [availableModels, setAvailableModels] = useState<Array<{ label: string; value: string }>>([])
const [ovmsConfig, setOvmsConfig] = useState<ConfigItem[]>([])
const { t } = useTranslation()
const providers = useAllProviders()
const providerOptions = Options.map((option) => {
const provider = providers.find((p) => p.id === option)
if (provider) {
return {
label: getProviderLabel(provider.id),
value: provider.id
}
} else {
return {
label: 'Unknown Provider',
value: undefined
}
}
})
const [generating, setGenerating] = useCache('chat.generating')
const navigate = useNavigate()
const location = useLocation()
const { autoTranslateWithSpace } = useSettings()
const spaceClickTimer = useRef<NodeJS.Timeout>(null)
const ovmsProvider = providers.find((p) => p.id === 'ovms')!
const getNewPainting = useCallback(() => {
if (availableModels.length > 0) {
return createDefaultOvmsPainting(availableModels)
}
return {
...DEFAULT_OVMS_PAINTING,
id: uuid()
}
}, [availableModels])
const textareaRef = useRef<any>(null)
// Load available models on component mount
useEffect(() => {
const loadModels = () => {
try {
// Get OVMS provider to access its models
const ovmsProvider = providers.find((p) => p.id === 'ovms')
const providerModels = ovmsProvider?.models || []
// Filter and format models for image generation
const filteredModels = getOvmsModels(providerModels)
setAvailableModels(filteredModels)
setOvmsConfig(createOvmsConfig(filteredModels))
// Update painting if it doesn't have a valid model
if (filteredModels.length > 0 && !filteredModels.some((m) => m.value === painting.model)) {
const defaultPainting = createDefaultOvmsPainting(filteredModels)
setPainting(defaultPainting)
}
} catch (error) {
logger.error(`Failed to load OVMS models: ${error}`)
// Use default config if loading fails
setOvmsConfig(createOvmsConfig())
}
}
loadModels()
}, [providers, painting.model]) // Re-run when providers change
const updatePaintingState = (updates: Partial<OvmsPainting>) => {
const updatedPainting = { ...painting, ...updates }
setPainting(updatedPainting)
updatePainting('ovms_paintings', updatedPainting)
}
const handleError = (error: unknown) => {
if (error instanceof Error && error.name !== 'AbortError') {
window.modal.error({
content: getErrorMessage(error),
centered: true
})
}
}
const downloadImages = async (urls: string[]) => {
const downloadedFiles = await Promise.all(
urls.map(async (url) => {
try {
if (!url?.trim()) {
logger.error('Image URL is empty, possibly due to prohibited prompt')
window.toast.warning(t('message.empty_url'))
return null
}
return await window.api.file.download(url)
} catch (error) {
logger.error(`Failed to download image: ${error}`)
if (
error instanceof Error &&
(error.message.includes('Failed to parse URL') || error.message.includes('Invalid URL'))
) {
window.toast.warning(t('message.empty_url'))
}
return null
}
})
)
return downloadedFiles.filter((file): file is FileMetadata => file !== null)
}
const onGenerate = async () => {
if (painting.files.length > 0) {
const confirmed = await window.modal.confirm({
content: t('paintings.regenerate.confirm'),
centered: true
})
if (!confirmed) return
await FileManager.deleteFiles(painting.files)
}
const prompt = textareaRef.current?.resizableTextArea?.textArea?.value || ''
updatePaintingState({ prompt })
if (!painting.model || !painting.prompt) {
return
}
const controller = new AbortController()
setAbortController(controller)
setIsLoading(true)
setGenerating(true)
try {
// Prepare request body for OVMS
const requestBody = {
model: painting.model,
prompt: painting.prompt,
size: painting.size || '512x512',
num_inference_steps: painting.num_inference_steps || 4,
rng_seed: painting.rng_seed || 0
}
logger.info('OVMS API request:', requestBody)
const response = await fetch(`${ovmsProvider.apiHost}images/generations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody),
signal: controller.signal
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } }))
logger.error('OVMS API error:', errorData)
throw new Error(errorData.error?.message || 'Image generation failed')
}
const data = await response.json()
logger.info('OVMS API response:', data)
// Handle base64 encoded images
if (data.data && data.data.length > 0) {
const base64s = data.data.filter((item) => item.b64_json).map((item) => item.b64_json)
if (base64s.length > 0) {
const validFiles = await Promise.all(
base64s.map(async (base64) => {
return await window.api.file.saveBase64Image(base64)
})
)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls: validFiles.map((file) => file.name) })
}
// Handle URL-based images if available
const urls = data.data.filter((item) => item.url).map((item) => item.url)
if (urls.length > 0) {
const validFiles = await downloadImages(urls)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls })
}
}
} catch (error: unknown) {
handleError(error)
} finally {
setIsLoading(false)
setGenerating(false)
setAbortController(null)
}
}
const handleRetry = async (painting: OvmsPainting) => {
setIsLoading(true)
try {
const validFiles = await downloadImages(painting.urls)
await FileManager.addFiles(validFiles)
updatePaintingState({ files: validFiles, urls: painting.urls })
} catch (error) {
handleError(error)
} finally {
setIsLoading(false)
}
}
const onCancel = () => {
abortController?.abort()
}
const nextImage = () => {
setCurrentImageIndex((prev) => (prev + 1) % painting.files.length)
}
const prevImage = () => {
setCurrentImageIndex((prev) => (prev - 1 + painting.files.length) % painting.files.length)
}
const handleAddPainting = () => {
const newPainting = addPainting('ovms_paintings', getNewPainting())
updatePainting('ovms_paintings', newPainting)
setPainting(newPainting)
return newPainting
}
const onDeletePainting = (paintingToDelete: OvmsPainting) => {
if (paintingToDelete.id === painting.id) {
const currentIndex = ovmsPaintings.findIndex((p) => p.id === paintingToDelete.id)
if (currentIndex > 0) {
setPainting(ovmsPaintings[currentIndex - 1])
} else if (ovmsPaintings.length > 1) {
setPainting(ovmsPaintings[1])
}
}
removePainting('ovms_paintings', paintingToDelete)
}
const translate = async () => {
if (isTranslating) {
return
}
if (!painting.prompt) {
return
}
try {
setIsTranslating(true)
const translatedText = await translateText(painting.prompt, LanguagesEnum.enUS)
updatePaintingState({ prompt: translatedText })
} catch (error) {
logger.error('Translation failed:', error as Error)
} finally {
setIsTranslating(false)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (autoTranslateWithSpace && event.key === ' ') {
setSpaceClickCount((prev) => prev + 1)
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
spaceClickTimer.current = setTimeout(() => {
setSpaceClickCount(0)
}, 200)
if (spaceClickCount === 2) {
setSpaceClickCount(0)
setIsTranslating(true)
translate()
}
}
}
const handleProviderChange = (providerId: string) => {
const routeName = location.pathname.split('/').pop()
if (providerId !== routeName) {
navigate('../' + providerId, { replace: true })
}
}
// Handle random seed generation
const handleRandomSeed = () => {
const randomSeed = Math.floor(Math.random() * 2147483647)
updatePaintingState({ rng_seed: randomSeed })
return randomSeed
}
// Render configuration form
const renderConfigForm = (item: ConfigItem) => {
switch (item.type) {
case 'select': {
const isDisabled = typeof item.disabled === 'function' ? item.disabled(item, painting) : item.disabled
const selectOptions =
typeof item.options === 'function'
? item.options(item, painting).map((option) => ({
...option,
label: option.label.startsWith('paintings.') ? t(option.label) : option.label
}))
: item.options?.map((option) => ({
...option,
label: option.label.startsWith('paintings.') ? t(option.label) : option.label
}))
return (
<Select
style={{ width: '100%' }}
listHeight={500}
disabled={isDisabled}
value={painting[item.key!] || item.initialValue}
options={selectOptions as any}
onChange={(v) => updatePaintingState({ [item.key!]: v })}
/>
)
}
case 'slider': {
return (
<SliderContainer>
<Slider
min={item.min}
max={item.max}
step={item.step}
value={(painting[item.key!] || item.initialValue) as number}
onChange={(v) => updatePaintingState({ [item.key!]: v })}
/>
<StyledInputNumber
min={item.min}
max={item.max}
step={item.step}
value={(painting[item.key!] || item.initialValue) as number}
onChange={(v) => updatePaintingState({ [item.key!]: v })}
/>
</SliderContainer>
)
}
case 'input':
return (
<Input
value={(painting[item.key!] || item.initialValue) as string}
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}
suffix={
item.key === 'rng_seed' ? (
<RedoOutlined onClick={handleRandomSeed} style={{ cursor: 'pointer', color: 'var(--color-text-2)' }} />
) : (
item.suffix
)
}
/>
)
case 'inputNumber':
return (
<InputNumber
min={item.min}
max={item.max}
style={{ width: '100%' }}
value={(painting[item.key!] || item.initialValue) as number}
onChange={(v) => updatePaintingState({ [item.key!]: v })}
/>
)
case 'textarea':
return (
<TextArea
value={(painting[item.key!] || item.initialValue) as string}
onChange={(e) => updatePaintingState({ [item.key!]: e.target.value })}
spellCheck={false}
rows={4}
/>
)
case 'switch':
return (
<RowFlex>
<Switch
checked={(painting[item.key!] || item.initialValue) as boolean}
onChange={(checked) => updatePaintingState({ [item.key!]: checked })}
/>
</RowFlex>
)
default:
return null
}
}
// Render configuration item
const renderConfigItem = (item: ConfigItem, index: number) => {
return (
<div key={index}>
<SettingTitle style={{ marginBottom: 5, marginTop: 15 }}>
{t(item.title!)}
{item.tooltip && (
<Tooltip title={t(item.tooltip)}>
<InfoIcon />
</Tooltip>
)}
</SettingTitle>
{renderConfigForm(item)}
</div>
)
}
const onSelectPainting = (newPainting: OvmsPainting) => {
if (generating) return
setPainting(newPainting)
setCurrentImageIndex(0)
}
useEffect(() => {
if (ovmsPaintings.length === 0) {
const newPainting = getNewPainting()
addPainting('ovms_paintings', newPainting)
setPainting(newPainting)
}
}, [ovmsPaintings, addPainting, getNewPainting])
useEffect(() => {
const timer = spaceClickTimer.current
return () => {
if (timer) {
clearTimeout(timer)
}
}
}, [])
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('paintings.title')}</NavbarCenter>
{isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<Button size="sm" className="nodrag" startContent={<PlusOutlined />} onPress={handleAddPainting}>
{t('paintings.button.new.image')}
</Button>
</NavbarRight>
)}
</Navbar>
<ContentContainer id="content-container">
<LeftContainer>
<Scrollbar>
<div style={{ padding: '20px' }}>
<ProviderTitleContainer>
<SettingTitle style={{ marginBottom: 5 }}>{t('common.provider')}</SettingTitle>
<SettingHelpLink
target="_blank"
href="https://docs.openvino.ai/2025/model-server/ovms_demos_image_generation.html">
{t('paintings.learn_more')}
<ProviderLogo
shape="square"
src={getProviderLogo(ovmsProvider.id)}
size={16}
style={{ marginLeft: 5 }}
/>
</SettingHelpLink>
</ProviderTitleContainer>
<Select
value={providerOptions.find((p) => p.value === 'ovms')?.value || 'ovms'}
onChange={handleProviderChange}
style={{ width: '100%', marginBottom: 15 }}>
{providerOptions.map((provider) => (
<Select.Option value={provider.value} key={provider.value}>
<SelectOptionContainer>
<ProviderLogo shape="square" src={getProviderLogo(provider.value || '')} size={16} />
{provider.label}
</SelectOptionContainer>
</Select.Option>
))}
</Select>
{/* Render configuration items using JSON config */}
{ovmsConfig.map(renderConfigItem)}
</div>
</Scrollbar>
</LeftContainer>
<MainContainer>
<Artboard
painting={painting}
isLoading={isLoading}
currentImageIndex={currentImageIndex}
onPrevImage={prevImage}
onNextImage={nextImage}
onCancel={onCancel}
retry={handleRetry}
/>
<InputContainer>
<Textarea
ref={textareaRef}
variant="borderless"
disabled={isLoading}
value={painting.prompt}
spellCheck={false}
onChange={(e) => updatePaintingState({ prompt: e.target.value })}
placeholder={isTranslating ? t('paintings.translating') : t('paintings.prompt_placeholder')}
onKeyDown={handleKeyDown}
/>
<Toolbar>
<ToolbarMenu>
<SendMessageButton
sendMessage={onGenerate}
disabled={isLoading || !painting.model || painting.model === OVMS_MODELS[0]?.value}
/>
</ToolbarMenu>
</Toolbar>
</InputContainer>
</MainContainer>
<PaintingsList
namespace="ovms_paintings"
paintings={ovmsPaintings}
selectedPainting={painting}
onSelectPainting={onSelectPainting}
onDeletePainting={onDeletePainting}
onNewPainting={handleAddPainting}
/>
</ContentContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
height: 100%;
background-color: var(--color-background);
overflow: hidden;
`
const LeftContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background-color: var(--color-background);
max-width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
overflow: hidden;
`
const MainContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
background-color: var(--color-background);
`
const InputContainer = styled.div`
display: flex;
flex-direction: column;
min-height: 95px;
max-height: 95px;
position: relative;
border: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
margin: 0 20px 15px 20px;
border-radius: 10px;
`
const Textarea = styled(TextArea)`
padding: 10px;
border-radius: 0;
display: flex;
flex: 1;
resize: none !important;
overflow: auto;
width: auto;
`
const Toolbar = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: flex-end;
padding: 0 8px;
padding-bottom: 0;
height: 40px;
`
const ToolbarMenu = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
`
const InfoIcon = styled(Info)`
margin-left: 5px;
cursor: help;
color: var(--color-text-2);
opacity: 0.6;
width: 14px;
height: 16px;
&:hover {
opacity: 1;
}
`
const SliderContainer = styled.div`
display: flex;
align-items: center;
gap: 16px;
.ant-slider {
flex: 1;
}
`
const StyledInputNumber = styled(InputNumber)`
width: 70px;
`
const ProviderLogo = styled(Avatar)`
border: 0.5px solid var(--color-border);
`
const ProviderTitleContainer = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
`
const SelectOptionContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
`
export default OvmsPage

View File

@ -5,19 +5,20 @@ import { useAppDispatch } from '@renderer/store'
import { setDefaultPaintingProvider } from '@renderer/store/settings' import { setDefaultPaintingProvider } from '@renderer/store/settings'
import type { PaintingProvider, SystemProviderId } from '@renderer/types' import type { PaintingProvider, SystemProviderId } from '@renderer/types'
import type { FC } from 'react' import type { FC } from 'react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Route, Routes, useParams } from 'react-router-dom' import { Route, Routes, useParams } from 'react-router-dom'
import AihubmixPage from './AihubmixPage' import AihubmixPage from './AihubmixPage'
import DmxapiPage from './DmxapiPage' import DmxapiPage from './DmxapiPage'
import NewApiPage from './NewApiPage' import NewApiPage from './NewApiPage'
import OvmsPage from './OvmsPage'
import SiliconPage from './SiliconPage' import SiliconPage from './SiliconPage'
import TokenFluxPage from './TokenFluxPage' import TokenFluxPage from './TokenFluxPage'
import ZhipuPage from './ZhipuPage' import ZhipuPage from './ZhipuPage'
const logger = loggerService.withContext('PaintingsRoutePage') const logger = loggerService.withContext('PaintingsRoutePage')
const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux'] const BASE_OPTIONS: SystemProviderId[] = ['zhipu', 'aihubmix', 'silicon', 'dmxapi', 'tokenflux', 'ovms']
const PaintingsRoutePage: FC = () => { const PaintingsRoutePage: FC = () => {
const params = useParams() const params = useParams()
@ -27,28 +28,41 @@ const PaintingsRoutePage: FC = () => {
const Options = useMemo(() => { const Options = useMemo(() => {
return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)] return [...BASE_OPTIONS, ...providers.filter((p) => isNewApiProvider(p)).map((p) => p.id)]
}, [providers]) }, [providers])
const [ovmsStatus, setOvmsStatus] = useState<'not-installed' | 'not-running' | 'running'>('not-running')
useEffect(() => {
const checkStatus = async () => {
const status = await window.api.ovms.getStatus()
setOvmsStatus(status)
}
checkStatus()
}, [])
const validOptions = Options.filter((option) => option !== 'ovms' || ovmsStatus === 'running')
useEffect(() => { useEffect(() => {
logger.debug(`defaultPaintingProvider: ${provider}`) logger.debug(`defaultPaintingProvider: ${provider}`)
if (provider && Options.includes(provider)) { if (provider && validOptions.includes(provider)) {
dispatch(setDefaultPaintingProvider(provider as PaintingProvider)) dispatch(setDefaultPaintingProvider(provider as PaintingProvider))
} }
}, [provider, dispatch, Options]) }, [provider, dispatch, validOptions])
return ( return (
<Routes> <Routes>
<Route path="*" element={<ZhipuPage Options={Options} />} /> <Route path="*" element={<ZhipuPage Options={validOptions} />} />
<Route path="/zhipu" element={<ZhipuPage Options={Options} />} /> <Route path="/zhipu" element={<ZhipuPage Options={validOptions} />} />
<Route path="/aihubmix" element={<AihubmixPage Options={Options} />} /> <Route path="/aihubmix" element={<AihubmixPage Options={validOptions} />} />
<Route path="/silicon" element={<SiliconPage Options={Options} />} /> <Route path="/silicon" element={<SiliconPage Options={validOptions} />} />
<Route path="/dmxapi" element={<DmxapiPage Options={Options} />} /> <Route path="/dmxapi" element={<DmxapiPage Options={validOptions} />} />
<Route path="/tokenflux" element={<TokenFluxPage Options={Options} />} /> <Route path="/tokenflux" element={<TokenFluxPage Options={validOptions} />} />
<Route path="/ovms" element={<OvmsPage Options={validOptions} />} />
{/* new-api family providers are mounted dynamically below */} {/* new-api family providers are mounted dynamically below */}
{providers {providers
.filter((p) => isNewApiProvider(p)) .filter((p) => isNewApiProvider(p))
.map((p) => ( .map((p) => (
<Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={Options} />} /> <Route key={p.id} path={`/${p.id}`} element={<NewApiPage Options={validOptions} />} />
))} ))}
<Route path="/new-api" element={<NewApiPage Options={validOptions} />} />
</Routes> </Routes>
) )
} }

View File

@ -0,0 +1,129 @@
import type { PaintingAction } from '@renderer/types'
import { uuid } from '@renderer/utils'
// Configuration item type definition
export type ConfigItem = {
type: 'select' | 'radio' | 'slider' | 'input' | 'switch' | 'inputNumber' | 'textarea' | 'title' | 'description'
key?: keyof PaintingAction | 'commonModel'
title?: string
tooltip?: string
options?:
| Array<{
label: string
title?: string
value?: string | number
icon?: string
}>
| ((
config: ConfigItem,
painting: Partial<PaintingAction>
) => Array<{ label: string; value: string | number; icon?: string }>)
min?: number
max?: number
step?: number
suffix?: React.ReactNode
content?: string
disabled?: boolean | ((config: ConfigItem, painting: Partial<PaintingAction>) => boolean)
initialValue?: string | number | boolean
required?: boolean
condition?: (painting: PaintingAction) => boolean
}
// Size options for OVMS
const SIZE_OPTIONS = [
{ label: '512x512', value: '512x512' },
{ label: '768x768', value: '768x768' },
{ label: '1024x1024', value: '1024x1024' }
]
// Available OVMS models for image generation - will be populated dynamically
export const OVMS_MODELS = [{ label: 'no available model', value: 'none' }]
// Function to get available OVMS models from provider
export const getOvmsModels = (
providerModels?: Array<{ id: string; name: string }>
): Array<{ label: string; value: string }> => {
if (!providerModels || providerModels.length === 0) {
// Fallback to static models if no provider models
return OVMS_MODELS
}
// Filter provider models for image generation (SD, Stable-Diffusion, Stable Diffusion, FLUX)
const imageGenerationModels = providerModels.filter((model) => {
const modelName = model.name.toLowerCase()
return (
modelName.startsWith('sd') ||
modelName.startsWith('stable-diffusion') ||
modelName.startsWith('stable diffusion') ||
modelName.startsWith('flux')
)
})
// Convert to the expected format
const formattedModels = imageGenerationModels.map((model) => ({
label: model.name,
value: model.id
}))
// Return formatted models or fallback to static models
return formattedModels.length > 0 ? formattedModels : OVMS_MODELS
}
// Create configuration function
export const createOvmsConfig = (models?: Array<{ label: string; value: string }>): ConfigItem[] => {
const availableModels = models || OVMS_MODELS
return [
{
type: 'select',
key: 'model',
title: 'paintings.model',
options: availableModels,
initialValue: availableModels[0]?.value || 'Select Model Here'
},
{
type: 'select',
key: 'size',
title: 'paintings.image.size',
options: SIZE_OPTIONS,
initialValue: '512x512'
},
{
type: 'inputNumber',
key: 'num_inference_steps',
title: 'paintings.inference_steps',
tooltip: 'paintings.inference_steps_tip',
min: 1,
max: 100,
initialValue: 4
},
{
type: 'inputNumber',
key: 'rng_seed',
title: 'paintings.seed',
tooltip: 'paintings.seed_tip',
initialValue: 0
}
]
}
// Default painting configuration for OVMS
export const DEFAULT_OVMS_PAINTING: PaintingAction = {
id: uuid(),
model: '',
prompt: '',
size: '512x512',
num_inference_steps: 4,
rng_seed: 0,
files: [],
urls: []
}
// Function to create default painting with dynamic model
export const createDefaultOvmsPainting = (models?: Array<{ label: string; value: string }>): PaintingAction => {
const availableModels = models || OVMS_MODELS
return {
...DEFAULT_OVMS_PAINTING,
id: uuid(),
model: availableModels[0]?.value || 'Select Model Here'
}
}

View File

@ -139,7 +139,8 @@ export async function fetchChatCompletion({
enableGenerateImage: capabilities.enableGenerateImage, enableGenerateImage: capabilities.enableGenerateImage,
enableUrlContext: capabilities.enableUrlContext, enableUrlContext: capabilities.enableUrlContext,
mcpTools, mcpTools,
uiMessages uiMessages,
knowledgeRecognition: assistant.knowledgeRecognition
} }
// --- Call AI Completions --- // --- Call AI Completions ---

View File

@ -2319,7 +2319,8 @@ const migrateConfig = {
// @ts-ignore upscale // @ts-ignore upscale
aihubmix_image_upscale: state?.paintings?.upscale || [], aihubmix_image_upscale: state?.paintings?.upscale || [],
openai_image_generate: state?.paintings?.openai_image_generate || [], openai_image_generate: state?.paintings?.openai_image_generate || [],
openai_image_edit: state?.paintings?.openai_image_edit || [] openai_image_edit: state?.paintings?.openai_image_edit || [],
ovms_paintings: []
} }
return state return state
@ -2680,6 +2681,7 @@ const migrateConfig = {
provider.anthropicApiHost = 'https://open.cherryin.net' provider.anthropicApiHost = 'https://open.cherryin.net'
} }
}) })
state.paintings.ovms_paintings = []
return state return state
} catch (error) { } catch (error) {
logger.error('migrate 163 error', error as Error) logger.error('migrate 163 error', error as Error)

View File

@ -20,7 +20,9 @@ const initialState: PaintingsState = {
aihubmix_image_upscale: [], aihubmix_image_upscale: [],
// OpenAI // OpenAI
openai_image_generate: [], openai_image_generate: [],
openai_image_edit: [] openai_image_edit: [],
// OVMS
ovms_paintings: []
} }
const paintingsSlice = createSlice({ const paintingsSlice = createSlice({

View File

@ -89,6 +89,7 @@ const ThinkModelTypes = [
'qwen_thinking', 'qwen_thinking',
'doubao', 'doubao',
'doubao_no_auto', 'doubao_no_auto',
'doubao_after_251015',
'hunyuan', 'hunyuan',
'zhipu', 'zhipu',
'perplexity', 'perplexity',
@ -277,7 +278,7 @@ export type PaintingParams = {
providerId?: string providerId?: string
} }
export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api' export type PaintingProvider = 'zhipu' | 'aihubmix' | 'silicon' | 'dmxapi' | 'new-api' | 'ovms'
export interface Painting extends PaintingParams { export interface Painting extends PaintingParams {
model?: string model?: string
@ -377,8 +378,18 @@ export interface TokenFluxPainting extends PaintingParams {
status?: 'starting' | 'processing' | 'succeeded' | 'failed' | 'cancelled' status?: 'starting' | 'processing' | 'succeeded' | 'failed' | 'cancelled'
} }
export interface OvmsPainting extends PaintingParams {
model?: string
prompt?: string
size?: string
num_inference_steps?: number
rng_seed?: number
safety_check?: boolean
response_format?: 'url' | 'b64_json'
}
export type PaintingAction = Partial< export type PaintingAction = Partial<
GeneratePainting & RemixPainting & EditPainting & ScalePainting & DmxapiPainting & TokenFluxPainting GeneratePainting & RemixPainting & EditPainting & ScalePainting & DmxapiPainting & TokenFluxPainting & OvmsPainting
> & > &
PaintingParams PaintingParams
@ -399,6 +410,8 @@ export interface PaintingsState {
// OpenAI // OpenAI
openai_image_generate: Partial<GeneratePainting> & PaintingParams[] openai_image_generate: Partial<GeneratePainting> & PaintingParams[]
openai_image_edit: Partial<EditPainting> & PaintingParams[] openai_image_edit: Partial<EditPainting> & PaintingParams[]
// OVMS
ovms_paintings: OvmsPainting[]
} }
export type MinAppType = { export type MinAppType = {

View File

@ -79,6 +79,7 @@ export type ReasoningEffortOptionalParams = {
thinking?: { type: 'disabled' | 'enabled' | 'auto'; budget_tokens?: number } thinking?: { type: 'disabled' | 'enabled' | 'auto'; budget_tokens?: number }
reasoning?: { max_tokens?: number; exclude?: boolean; effort?: string; enabled?: boolean } | OpenAI.Reasoning reasoning?: { max_tokens?: number; exclude?: boolean; effort?: string; enabled?: boolean } | OpenAI.Reasoning
reasoningEffort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'none' | 'auto' reasoningEffort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'none' | 'auto'
// WARN: This field will be overwrite to undefined by aisdk if the provider is openai-compatible. Use reasoningEffort instead.
reasoning_effort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'none' | 'auto' reasoning_effort?: OpenAI.Chat.Completions.ChatCompletionCreateParams['reasoning_effort'] | 'none' | 'auto'
enable_thinking?: boolean enable_thinking?: boolean
thinking_budget?: number thinking_budget?: number

1320
yarn.lock

File diff suppressed because it is too large Load Diff