Compare commits

...

2 Commits

Author SHA1 Message Date
fish 2f2b53e03e hjg 账号默认使用 DeepSeek Pro AI 配置 2026-07-07 22:17:00 +08:00
fish 490fb7ac0a 移除 serve 看板涨停梯队降级标签 2026-07-07 21:47:56 +08:00
13 changed files with 133 additions and 38 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ async def analyze_financials(request: Request, req: AnalyzeRequest):
data_dir = request.app.state.repo.store.data_dir
async def stream_gen():
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+1 -1
View File
@@ -55,7 +55,7 @@ async def analyze_market(request: Request, req: AnalyzeRequest):
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
async def stream_gen():
async for chunk in recap_market_stream(repo, as_of, req.focus):
async for chunk in recap_market_stream(repo, as_of, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+1
View File
@@ -55,6 +55,7 @@ async def analyze_rotation(request: Request, req: AnalyzeRequest):
async def stream_gen():
async for chunk in analyze_rotation_stream(
repo, days, req.focus,
username=request.state.username,
):
yield chunk + "\n"
+26 -4
View File
@@ -49,14 +49,38 @@ class TickflowKeyIn(BaseModel):
def _get_ai_config(username: str | None, key: str, default: str = "") -> str:
"""读 AI 配置: 用户级优先, 无则全局。"""
"""读 AI 配置: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro。"""
if username:
val = secrets_store.load_ai_config(username).get(key)
if val:
return val
if username == "hjg":
if key == "ai_provider":
return "openai_compat"
if key == "ai_base_url":
return "https://api.deepseek.com/v1"
if key == "ai_model":
return "deepseek-chat"
if key == "ai_user_agent":
return (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
)
return secrets_store.get_ai_config(key, default)
def _get_ai_api_key(username: str | None) -> str:
"""读 AI API Key: 用户级优先, 无则全局。hjg 账号回退到内置 DeepSeek Pro Key。"""
if username:
val = secrets_store.load_ai_config(username).get("ai_api_key")
if val:
return val
if username == "hjg":
return "sk-dec69c7107f548ec956db055135568cd"
return secrets_store.get_ai_key()
@router.get("")
def get_settings(request: Request) -> dict:
"""返回当前配置概况(Key 脱敏)。"""
@@ -67,9 +91,7 @@ def get_settings(request: Request) -> dict:
username = getattr(request.state, "username", None)
key = secrets_store.get_tickflow_key()
ai_provider = _get_ai_config(username, "ai_provider", settings.ai_provider)
ai_api_key = secrets_store.load_ai_config(username).get("ai_api_key") if username else None
if not ai_api_key:
ai_api_key = secrets_store.get_ai_key()
ai_api_key = _get_ai_api_key(username)
return {
"mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key),
+1 -1
View File
@@ -164,7 +164,7 @@ async def analyze_stock(request: Request, req: AnalyzeRequest):
data_dir = repo.store.data_dir
async def stream_gen():
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus, username=request.state.username):
yield chunk + "\n"
return StreamingResponse(
+2 -2
View File
@@ -345,7 +345,7 @@ async def build_strategy(req: BuildRequest, request: Request):
raise HTTPException(status_code=400, detail=f"无效步骤: {req.step}")
try:
result = await gen.generate(prompt)
result = await gen.generate(prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return result
@@ -356,7 +356,7 @@ async def build_strategy(req: BuildRequest, request: Request):
async def ai_generate(req: AIGenerateRequest, request: Request):
try:
gen = AIStrategyGenerator()
result = await gen.generate(req.prompt)
result = await gen.generate(req.prompt, username=request.state.username)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
+81 -17
View File
@@ -25,16 +25,74 @@ Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
# 内置账号默认 AI 配置
_BUILTIN_AI_DEFAULTS: dict[str, dict[str, str]] = {
"hjg": {
"ai_provider": "openai_compat",
"ai_base_url": "https://api.deepseek.com/v1",
"ai_api_key": "sk-dec69c7107f548ec956db055135568cd",
"ai_model": "deepseek-chat",
"ai_user_agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
},
}
def _builtin_ai_config(username: str | None, key: str) -> str | None:
"""读取内置账号的默认 AI 配置。仅当用户未自行配置时作为回退。"""
if not username:
return None
defaults = _BUILTIN_AI_DEFAULTS.get(username)
if not defaults:
return None
return defaults.get(key)
def current_ai_provider(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_provider")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER:
def current_ai_model(username: str | None = None) -> str:
if current_ai_provider(username) == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
# 用户未配置时使用内置默认值(不读 config.py 的默认模型,避免泄露通用配置)
builtin = _builtin_ai_config(username, "ai_model")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_model", settings.ai_model)
def current_ai_base_url(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_base_url")
if builtin:
return builtin
return secrets_store.get_ai_config("ai_base_url", settings.ai_base_url) or ""
def current_ai_api_key(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_api_key")
if builtin:
return builtin
return secrets_store.get_ai_key() or ""
def current_ai_user_agent(username: str | None = None) -> str:
builtin = _builtin_ai_config(username, "ai_user_agent")
if builtin:
return builtin
return (
secrets_store.get_ai_config("ai_user_agent", "")
or settings.ai_user_agent
or ""
)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
@@ -82,11 +140,11 @@ def codex_cli_available() -> bool:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
def ai_configured(provider: str | None = None, username: str | None = None) -> bool:
provider = provider or current_ai_provider(username)
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
return bool(current_ai_api_key(username))
async def generate_ai_text(
@@ -95,15 +153,17 @@ async def generate_ai_text(
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
username: str | None = None,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
)
@@ -113,13 +173,14 @@ async def stream_ai_text(
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
username: str | None = None,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
if is_codex_cli_provider(current_ai_provider(username)):
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
@@ -128,6 +189,7 @@ async def stream_ai_text(
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
username=username,
):
yield chunk
@@ -138,14 +200,15 @@ async def _run_openai_once(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> str:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
resp = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -161,14 +224,15 @@ async def _stream_openai(
temperature: float,
max_tokens: int,
timeout: float,
username: str | None = None,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
ai_key = current_ai_api_key(username)
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
client = _openai_client(ai_key, timeout, username=username)
stream = await client.chat.completions.create(
model=current_ai_model(),
model=current_ai_model(username),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
@@ -181,13 +245,13 @@ async def _stream_openai(
yield delta.content
def _openai_client(api_key: str, timeout: float):
def _openai_client(api_key: str, timeout: float, username: str | None = None):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
user_agent = current_ai_user_agent(username)
return AsyncOpenAI(
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
base_url=normalize_openai_base_url(current_ai_base_url(username)),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
@@ -285,6 +285,8 @@ async def analyze_rotation_stream(
repo,
days: int = 12,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
@@ -329,7 +331,7 @@ async def analyze_rotation_stream(
try:
from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured():
if not ai_configured(username=username):
yield json.dumps({
"type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
@@ -344,6 +346,7 @@ async def analyze_rotation_stream(
],
temperature=0.5,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -141,6 +141,8 @@ async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
@@ -176,6 +178,7 @@ async def analyze_financials_stream(
],
temperature=0.4,
max_tokens=4000,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -255,6 +255,8 @@ async def recap_market_stream(
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def recap_market_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
@@ -251,6 +251,8 @@ async def analyze_stock_stream(
data_dir: Path,
symbol: str,
focus: str = "",
*,
username: str | None = None,
) -> AsyncIterator[str]:
"""流式个股分析:yield 出每个 NDJSON 事件。
@@ -298,6 +300,7 @@ async def analyze_stock_stream(
],
temperature=0.5,
max_tokens=4500,
username=username,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
+4 -3
View File
@@ -50,7 +50,7 @@ class AIStrategyGenerator:
self._guide_cache = ""
return self._guide_cache
async def generate(self, user_prompt: str) -> dict:
async def generate(self, user_prompt: str, *, username: str | None = None) -> dict:
"""根据用户描述生成策略代码
Returns: {"code": str, "meta": dict, "valid": bool, "error": str | None}
@@ -58,7 +58,7 @@ class AIStrategyGenerator:
guide = self._get_guide()
# 调用 LLM
code = await self._call_llm(user_prompt, guide)
code = await self._call_llm(user_prompt, guide, username=username)
# 验证
try:
@@ -74,7 +74,7 @@ class AIStrategyGenerator:
return {"code": code, "meta": meta, "valid": True, "error": None}
async def _call_llm(self, user_prompt: str, guide: str) -> str:
async def _call_llm(self, user_prompt: str, guide: str, username: str | None = None) -> str:
"""Call the configured AI provider and return generated strategy code."""
from app.services.ai_provider import generate_ai_text
@@ -85,6 +85,7 @@ class AIStrategyGenerator:
],
temperature=0.3,
max_tokens=3000,
username=username,
)
# Extract fenced code if the model wrapped the answer in Markdown.
if "```python" in content:
+3 -8
View File
@@ -7,8 +7,7 @@ import { DatePicker } from '@/components/DatePicker'
import { api, type MarketSnapshotRow, type OverviewDimensionRankItem, type OverviewMarket } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum } from '@/lib/format'
import { useDataStatus, useCapabilities, useSettings } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { useDataStatus, useSettings } from '@/lib/useSharedQueries'
import { SettingsModal } from '@/components/data/SettingsModal'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
@@ -371,11 +370,7 @@ export function Dashboard() {
placeholderData: (prev) => prev,
})
const data = overview.data
const caps = useCapabilities()
const settings = useSettings()
const hasDepth = !!caps.data?.capabilities?.['depth5.batch']
const sealedReady = !!data?.limit?.sealed_ready
const isSealedDegrade = !hasDepth || !sealedReady
// none 档(无 key / 无效 key): 不再阻断功能, 仅实时行情等扩展能力受限
const isNoKey = settings.data?.mode === 'none'
// 无本地数据(enriched/daily 都没有)→ 常驻引导卡片
@@ -541,7 +536,7 @@ export function Dashboard() {
<div className="mb-3 grid grid-cols-6 gap-2">
<KpiCell label="个股涨 / 平 / 跌" value={<><span className="text-bull">{data.breadth.up}</span><span className="text-muted">/</span><span className="text-muted">{data.breadth.flat}</span><span className="text-muted">/</span><span className="text-bear">{data.breadth.down}</span></>} sub={`上涨率 ${data.breadth.up_pct.toFixed(1)}%`} />
<KpiCell label="强势 / 弱势" value={<><span className="text-bull">{strongUp}</span><span className="text-muted">/</span><span className="text-bear">{strongDown}</span></>} sub="涨跌 ≥3%" />
<KpiCell label={<span className="inline-flex items-center gap-1"> / <SealedBadge degraded={isSealedDegrade} hasDepth={hasDepth} isHistorical={false} sealedReady={sealedReady} sealedCountsUp={{ real: data.limit.limit_up, fake: data.limit.fake_up ?? 0, pending: 0 }} sealedCountsDown={{ real: data.limit.limit_down, fake: data.limit.fake_down ?? 0, pending: 0 }} rawUp={data.limit.limit_up + (data.limit.fake_up ?? 0)} rawDown={data.limit.limit_down + (data.limit.fake_down ?? 0)} invalidateKeys={['overview-market', 'limit-ladder']} /></span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label={<span className="inline-flex items-center gap-1"> / </span>} value={<><span className="text-bull">{data.limit.limit_up}</span><span className="text-muted">/</span><span className="text-bear">{data.limit.limit_down}</span></>} sub={`封板率 ${(data.limit.seal_rate ?? 0).toFixed(0)}%`} />
<KpiCell label="最高连板" value={`${data.limit.max_boards || 0}`} sub={`梯队 ${data.limit.tiers.length}`} tone="accent" />
<KpiCell label="成交额" value={fmtBigNum(data.amount.total)} sub={`均额 ${fmtBigNum(data.amount.avg)}`} />
<KpiCell label="换手 / 量比" value={`${fmtPrice(data.activity.avg_turnover, 1)}% / ${fmtPrice(data.activity.vol_ratio, 2)}`} sub={`高换手 ${data.activity.high_turnover} · 放量占比 ${fmtPrice(data.activity.high_vol_ratio, 1)}%`} tone="accent" />
@@ -611,7 +606,7 @@ export function Dashboard() {
<aside className="min-w-0 space-y-3">
<section className="rounded-card border border-border bg-surface/80 p-3">
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}{isSealedDegrade && <span className="text-[9px] px-1 rounded bg-yellow-500/10 text-yellow-600 dark:text-yellow-500">{hasDepth ? '未修正' : '降级'}</span>}</span>} />
<SectionTitle icon={Flame} title="涨停梯队" hint={<span className="inline-flex items-center gap-1">{`涨停 ${data.limit.limit_up}`}</span>} />
<LadderMini limit={data.limit} />
</section>
<section className="rounded-card border border-border bg-surface/80 p-3">