release: bump version to 0.50.0

This commit is contained in:
rayd1o
2026-05-10 22:06:01 +08:00
parent e1984c7a35
commit 455b8360d0
80 changed files with 10936 additions and 298 deletions

View File

@@ -484,7 +484,8 @@ body {
flex: 1 1 auto;
min-height: 0;
height: 100%;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 14px;
@@ -530,6 +531,10 @@ body {
}
.playground-chat__input-wrap {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.22);
background:
@@ -665,6 +670,9 @@ body {
}
.playground-message__bubble {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
width: 100%;
border-radius: 18px;
padding: 14px 16px;
@@ -839,10 +847,16 @@ body {
.playground-message__markdown {
font-size: 13px;
line-height: 1.7;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.playground-message__markdown .markdown-renderer {
color: inherit;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.playground-message__markdown .markdown-renderer > :last-child {
@@ -976,6 +990,11 @@ body {
gap: 8px;
}
.playground-preset-strip__actions--composer {
gap: 6px;
margin-bottom: 6px;
}
.playground-preset-strip--chat {
gap: 8px;
margin-bottom: 14px;
@@ -988,6 +1007,11 @@ body {
padding: 4px 12px;
}
.playground-preset-strip__actions--composer .ant-tag-checkable {
margin-bottom: 0;
padding: 3px 10px;
}
.playground-preset-strip__actions .ant-tag-checkable-checked {
background: linear-gradient(135deg, #0f766e, #14b8a6);
color: #f8fafc;

View File

@@ -73,9 +73,13 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'Planet 使用手册', group: 'Manual', order: 2 },
en: { title: 'Planet Manual', group: 'Manual', order: 2 },
},
'faq.md': {
zh: { title: '常见问题', group: 'Manual', order: 3 },
en: { title: 'FAQ', group: 'Manual', order: 3 },
},
'location-pipeline-user.md': {
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 3 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 3 },
zh: { title: 'Earth 位置候选采集使用手册', group: 'Manual', order: 4 },
en: { title: 'Earth Location Candidate Collection User Guide', group: 'Manual', order: 4 },
},
'earth-frontend-context.md': {
zh: { title: 'Earth 前端结构', group: 'Earth', order: 10 },

View File

@@ -244,6 +244,7 @@ function Playground() {
const pollTimerRef = useRef<number | null>(null)
const messagesContainerRef = useRef<HTMLDivElement | null>(null)
const messagesShellRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLDivElement | null>(null)
const forceScrollToBottomRef = useRef(true)
const selectedPreset = useMemo(
@@ -256,6 +257,7 @@ function Playground() {
)
const streaming = Boolean(activeAssistantMessage)
const sendButtonTooltip = requestPending ? '发送中' : streaming ? '停止生成' : '发送消息'
const composerExpanded = composerFocused || !!inputValue
const applyThreadSnapshot = (thread: PlaygroundThreadRecord | null) => {
if (!thread) {
@@ -423,6 +425,7 @@ function Playground() {
}, [])
const handleApplyPreset = (preset: PlaygroundPreset) => {
setComposerFocused(true)
setSelectedPresetKey(preset.key)
setTitle(preset.values.title)
setObjective(preset.values.objective)
@@ -430,6 +433,15 @@ function Playground() {
setInputValue(preset.values.message)
}
const handleComposerBlur = () => {
window.setTimeout(() => {
const composer = composerRef.current
if (!composer || !composer.contains(document.activeElement)) {
setComposerFocused(false)
}
}, 0)
}
const handleStop = async () => {
if (!token || !activeAssistantMessage) {
return
@@ -864,9 +876,17 @@ function Playground() {
</div>
<div className="playground-chat__composer">
<div className={`playground-chat__input-wrap${composerFocused || !!inputValue ? ' playground-chat__input-wrap--expanded' : ''}`}>
{(composerFocused || !!inputValue) && (
<div className="playground-preset-strip__actions">
<div
ref={composerRef}
className={`playground-chat__input-wrap${composerExpanded ? ' playground-chat__input-wrap--expanded' : ''}`}
onFocusCapture={() => setComposerFocused(true)}
onBlurCapture={handleComposerBlur}
>
{composerExpanded && (
<div
className="playground-preset-strip__actions playground-preset-strip__actions--composer"
onMouseDown={(event) => event.preventDefault()}
>
{PLAYGROUND_PRESETS.map((preset) => (
<Tag.CheckableTag
key={preset.key}
@@ -882,11 +902,9 @@ function Playground() {
<Input.TextArea
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
autoSize={composerFocused || !!inputValue ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
autoSize={composerExpanded ? { minRows: 4, maxRows: 10 } : { minRows: 1, maxRows: 1 }}
placeholder="在这里输入本次分析请求..."
className="playground-chat__input"
onFocus={() => setComposerFocused(true)}
onBlur={() => setComposerFocused(false)}
onPressEnter={(event) => {
if (!event.shiftKey) {
event.preventDefault()
@@ -894,7 +912,7 @@ function Playground() {
}
}}
/>
{!(composerFocused || !!inputValue) && (
{!composerExpanded && (
<Tooltip title={sendButtonTooltip}>
<Button
type="text"
@@ -907,7 +925,7 @@ function Playground() {
</Tooltip>
)}
</div>
{(composerFocused || !!inputValue) && (
{composerExpanded && (
<div className="playground-chat__actions">
<div className="playground-chat__hints">
<Tag>{title}</Tag>

View File

@@ -7,6 +7,8 @@ import {
CheckCircleOutlined,
DeleteOutlined,
EditOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
PlusOutlined,
ReloadOutlined,
@@ -129,17 +131,31 @@ interface TVSettings {
interface SecretStatus {
configured: boolean
preview: string
source?: string
}
interface AIProviderConfig {
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
max_tokens: number
anthropic_version: string
source?: string
}
interface ExternalIntegrations {
ai_provider: {
service_url: string
service_token: SecretStatus
default_provider: string
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
providers: Record<string, AIProviderConfig>
max_tokens: number
anthropic_version: string
timeout_seconds: number
@@ -330,6 +346,11 @@ function Settings() {
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [savingIntegrations, setSavingIntegrations] = useState(false)
const [aiProviderSaveFeedback, setAiProviderSaveFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [testingCredentialProvider, setTestingCredentialProvider] = useState<string | null>(null)
const [credentialGuide, setCredentialGuide] = useState<CredentialGuide | null>(null)
const [credentialGuideOpen, setCredentialGuideOpen] = useState(false)
@@ -348,6 +369,9 @@ function Settings() {
const [customSourceForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
id: -(config.config_id || 0),
name: config.name,
@@ -458,18 +482,21 @@ function Settings() {
integrationForm.setFieldsValue({
ai_provider: {
service_url: integrations.ai_provider.service_url,
service_token: '',
service_token: integrations.ai_provider.service_token.configured
? integrations.ai_provider.service_token.preview
: '',
default_provider: integrations.ai_provider.default_provider || integrations.ai_provider.provider,
provider: integrations.ai_provider.provider,
provider_api: integrations.ai_provider.provider_api,
base_url: integrations.ai_provider.base_url,
model: integrations.ai_provider.model,
api_key: '',
api_key: integrations.ai_provider.api_key.configured
? integrations.ai_provider.api_key.preview
: '',
max_tokens: integrations.ai_provider.max_tokens,
anthropic_version: integrations.ai_provider.anthropic_version,
timeout_seconds: integrations.ai_provider.timeout_seconds,
retry_attempts: integrations.ai_provider.retry_attempts,
clear_service_token: false,
clear_api_key: false,
},
barentswatch: {
endpoint: integrations.barentswatch.endpoint,
@@ -607,8 +634,10 @@ function Settings() {
const saveIntegrations = async (values: any) => {
try {
setSavingIntegrations(true)
const aiProviderPayload = buildAiProviderDraftPayload(values)
const payload = {
...values,
ai_provider: aiProviderPayload,
barentswatch: {
...values.barentswatch,
client_secret:
@@ -619,15 +648,149 @@ function Settings() {
}
const response = await axios.put('/api/v1/settings/integrations', payload)
setIntegrations(response.data.integrations)
setAiProviderSaveFeedback({ type: 'success', message: 'AI 配置已保存为全局默认配置' })
message.success('外部集成配置已保存')
await fetchSettings()
} catch {
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
message.error('外部集成配置保存失败')
} finally {
setSavingIntegrations(false)
}
}
const buildAiProviderDraftPayload = (values: any) => {
const selectedProvider = String(values.ai_provider?.provider || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[selectedProvider]?.api_key
|| integrations?.ai_provider.api_key
const revealedSecrets = revealedAiProviderSecrets[selectedProvider]
const nextApiKey = String(values.ai_provider?.api_key || '').trim()
const apiKeyUnchanged = !nextApiKey
|| nextApiKey === providerSecret?.preview
|| nextApiKey === revealedSecrets?.api_key
|| nextApiKey.startsWith('••••')
|| nextApiKey.includes('*')
const nextServiceToken = String(values.ai_provider?.service_token || '').trim()
const serviceTokenUnchanged = !nextServiceToken
|| nextServiceToken === integrations?.ai_provider.service_token.preview
|| nextServiceToken === revealedSecrets?.service_token
|| nextServiceToken.startsWith('••••')
|| nextServiceToken.includes('*')
return {
...values.ai_provider,
default_provider: selectedProvider,
api_key: apiKeyUnchanged ? '' : nextApiKey,
service_token: serviceTokenUnchanged ? '' : nextServiceToken,
}
}
const revealAiProviderSecrets = async (provider: string) => {
const cached = revealedAiProviderSecrets[provider]
if (cached) return cached
const response = await axios.get('/api/v1/settings/integrations/ai-provider/secrets', {
params: { provider },
})
const secrets = {
api_key: String(response.data.api_key || ''),
service_token: String(response.data.service_token || ''),
}
setRevealedAiProviderSecrets((prev) => ({ ...prev, [provider]: secrets }))
return secrets
}
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(integrationForm.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.api_key) {
integrationForm.setFieldValue(['ai_provider', 'api_key'], secrets.api_key)
}
setAiProviderApiKeyRevealed(true)
} catch {
message.error('读取 LLM API Key 失败')
}
return
}
const currentValue = String(integrationForm.getFieldValue(['ai_provider', 'api_key']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.api_key
if (revealedValue && currentValue === revealedValue) {
integrationForm.setFieldValue(['ai_provider', 'api_key'], providerSecret?.preview || '')
}
setAiProviderApiKeyRevealed(false)
}
const handleServiceTokenVisibleChange = async (visible: boolean) => {
const provider = String(integrationForm.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
if (visible) {
try {
const secrets = await revealAiProviderSecrets(provider)
if (secrets.service_token) {
integrationForm.setFieldValue(['ai_provider', 'service_token'], secrets.service_token)
}
setServiceTokenRevealed(true)
} catch {
message.error('读取代理 Token 失败')
}
return
}
const currentValue = String(integrationForm.getFieldValue(['ai_provider', 'service_token']) || '')
const revealedValue = revealedAiProviderSecrets[provider]?.service_token
if (revealedValue && currentValue === revealedValue) {
integrationForm.setFieldValue(['ai_provider', 'service_token'], integrations?.ai_provider.service_token.preview || '')
}
setServiceTokenRevealed(false)
}
const testAiProviderConnection = async () => {
try {
setTestingAiProviderConnection(true)
const values = integrationForm.getFieldsValue(true)
const response = await axios.post(
'/api/v1/settings/integrations/ai-provider/connect',
buildAiProviderDraftPayload(values),
)
if (response.data.success || response.data.connected) {
if (response.data.integrations) {
setIntegrations(response.data.integrations)
integrationForm.setFieldsValue({
ai_provider: {
service_url: response.data.integrations.ai_provider.service_url,
service_token: response.data.integrations.ai_provider.service_token.configured
? response.data.integrations.ai_provider.service_token.preview
: '',
default_provider: response.data.integrations.ai_provider.default_provider || response.data.integrations.ai_provider.provider,
provider: response.data.integrations.ai_provider.provider,
provider_api: response.data.integrations.ai_provider.provider_api,
base_url: response.data.integrations.ai_provider.base_url,
model: response.data.integrations.ai_provider.model,
api_key: response.data.integrations.ai_provider.api_key.configured
? response.data.integrations.ai_provider.api_key.preview
: '',
max_tokens: response.data.integrations.ai_provider.max_tokens,
anthropic_version: response.data.integrations.ai_provider.anthropic_version,
timeout_seconds: response.data.integrations.ai_provider.timeout_seconds,
retry_attempts: response.data.integrations.ai_provider.retry_attempts,
},
})
}
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功,已保存为全局默认配置' })
message.success(response.data.message || 'AI Provider 连接成功')
} else {
setAiProviderSaveFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
message.error(response.data.message || 'AI Provider 连接失败')
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI Provider 连接失败'
setAiProviderSaveFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setTestingAiProviderConnection(false)
}
}
const saveSelectedCollectorSettings = async () => {
if (!selectedCollector || !selectedCollectorConfig) return
try {
@@ -1064,21 +1227,31 @@ function Settings() {
}
}
const applyAiProviderPreset = (preset: AIProviderPreset) => {
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.ai_provider.providers?.[provider]
const providerApi = savedProvider?.provider_api || preset?.provider_api || 'openai-completions'
const apiKey = savedProvider?.api_key.configured ? savedProvider.api_key.preview : ''
integrationForm.setFieldsValue({
ai_provider: {
provider: preset.provider,
provider_api: preset.provider_api,
base_url: preset.base_url,
model: preset.model,
max_tokens: preset.provider_api === 'anthropic-messages'
default_provider: provider,
provider,
provider_api: providerApi,
base_url: savedProvider?.base_url || preset?.base_url || '',
model: savedProvider?.model || preset?.model || '',
api_key: apiKey,
max_tokens: savedProvider?.max_tokens || (providerApi === 'anthropic-messages'
? ANTHROPIC_MESSAGES_MAX_TOKENS
: DEFAULT_PROVIDER_MAX_TOKENS,
anthropic_version: '2023-06-01',
: DEFAULT_PROVIDER_MAX_TOKENS),
anthropic_version: savedProvider?.anthropic_version || '2023-06-01',
},
})
}
const applyAiProviderPreset = (preset: AIProviderPreset) => {
applyAiProviderSelection(preset.provider, preset)
}
const refreshSelectedAiProviderPreset = async () => {
const provider = integrationForm.getFieldValue(['ai_provider', 'provider'])
if (!provider) return
@@ -1624,7 +1797,12 @@ function Settings() {
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={integrationForm} layout="vertical" onFinish={saveIntegrations}>
<Form
form={integrationForm}
layout="vertical"
onFinish={saveIntegrations}
onValuesChange={() => setAiProviderSaveFeedback(null)}
>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
@@ -1635,8 +1813,7 @@ function Settings() {
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => {
const preset = aiProviderPresets.find((item) => item.provider === value)
if (preset) applyAiProviderPreset(preset)
applyAiProviderSelection(value)
}}
/>
</Form.Item>
@@ -1646,13 +1823,22 @@ function Settings() {
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
</Button>
<Space>
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
</Button>
<Button
icon={<PlayCircleOutlined />}
loading={testingAiProviderConnection}
onClick={() => { void testAiProviderConnection() }}
>
</Button>
</Space>
</Form.Item>
</div>
@@ -1678,21 +1864,28 @@ function Settings() {
<Form.Item label="LLM API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.api_key.configured ? 'green' : 'default'}>
{integrations?.ai_provider.api_key.configured
? `已配置 ${integrations.ai_provider.api_key.preview}`
: '未配置'}
<Tag color={selectedAiProviderSecret?.configured ? 'green' : 'default'}>
{selectedAiProviderSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> key</Text>
<Text type="secondary"> Provider key</Text>
</Space>
<Form.Item name={['ai_provider', 'api_key']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的 LLM API key" />
<Input
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['ai_provider', 'clear_api_key']} valuePropName="checked">
<Checkbox> LLM API key</Checkbox>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ai_provider', 'max_tokens']} label="最大输出 Tokens">
<InputNumber min={1} max={200000} style={{ width: '100%' }} />
@@ -1715,20 +1908,27 @@ function Settings() {
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
{integrations?.ai_provider.service_token.configured
? `已配置 ${integrations.ai_provider.service_token.preview}`
: '未配置'}
{integrations?.ai_provider.service_token.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> backend aiprovider</Text>
</Space>
<Form.Item name={['ai_provider', 'service_token']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的代理 token" />
<Input
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['ai_provider', 'clear_service_token']} valuePropName="checked">
<Checkbox> token</Checkbox>
</Form.Item>
</Card>
</Card>
@@ -1740,6 +1940,14 @@ function Settings() {
>
AI
</Button>
{aiProviderSaveFeedback ? (
<Alert
showIcon
type={aiProviderSaveFeedback.type}
message={aiProviderSaveFeedback.message}
style={{ marginTop: 12 }}
/>
) : null}
</Form>
</SettingsPanel>
),