From 3535ba57abd5a78f89d9932eddecf9054d83d11d Mon Sep 17 00:00:00 2001 From: ArvinLovegood Date: Fri, 25 Apr 2025 16:33:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=E6=B7=BB=E5=8A=A0AI=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E8=B5=84=E8=AE=AF=E6=80=BB=E7=BB=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在市场组件中增加 AI 总结按钮和模态框 - 实现 SummaryStockNews 函数用于获取 AI 总结 - 添加 GetNewsList 方法获取市场新闻列表 - 优化市场资讯的展示和交互 --- app.go | 8 + backend/data/market_news_api.go | 21 +- backend/data/openai_api.go | 306 +++++++++++++++++++---------- frontend/src/components/market.vue | 197 ++++++++++++++++++- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + main.go | 6 +- 7 files changed, 430 insertions(+), 114 deletions(-) diff --git a/app.go b/app.go index 6bc01e9..61323e5 100644 --- a/app.go +++ b/app.go @@ -1077,3 +1077,11 @@ func (a *App) GetTelegraphList(source string) *[]*models.Telegraph { func (a *App) GlobalStockIndexes() map[string]any { return data.NewMarketNewsApi().GlobalStockIndexes(30) } + +func (a *App) SummaryStockNews(question string, sysPromptId *int) { + msgs := data.NewDeepSeekOpenAi(a.ctx).NewSummaryStockNewsStream(question, sysPromptId) + for msg := range msgs { + runtime.EventsEmit(a.ctx, "summaryStockNews", msg) + } + runtime.EventsEmit(a.ctx, "summaryStockNews", "DONE") +} diff --git a/backend/data/market_news_api.go b/backend/data/market_news_api.go index d493e8d..7189ae3 100644 --- a/backend/data/market_news_api.go +++ b/backend/data/market_news_api.go @@ -90,7 +90,26 @@ func (m MarketNewsApi) GetNewTelegraph(crawlTimeOut int64) *[]models.Telegraph { }) return &telegraphs } - +func (m MarketNewsApi) GetNewsList(source string, limit int) *[]*models.Telegraph { + news := &[]*models.Telegraph{} + if source != "" { + db.Dao.Model(news).Preload("TelegraphTags").Where("source=?", source).Order("id desc").Limit(limit).Find(news) + } else { + db.Dao.Model(news).Preload("TelegraphTags").Order("id desc").Limit(limit).Find(news) + } + for _, item := range *news { + tags := &[]models.Tags{} + db.Dao.Model(&models.Tags{}).Where("id in ?", lo.Map(item.TelegraphTags, func(item models.TelegraphTags, index int) uint { + return item.TagId + })).Find(&tags) + tagNames := lo.Map(*tags, func(item models.Tags, index int) string { + return item.Name + }) + item.SubjectTags = tagNames + logger.SugaredLogger.Infof("tagNames %v ,SubjectTags:%s", tagNames, item.SubjectTags) + } + return news +} func (m MarketNewsApi) GetTelegraphList(source string) *[]*models.Telegraph { news := &[]*models.Telegraph{} if source != "" { diff --git a/backend/data/openai_api.go b/backend/data/openai_api.go index 4dcf0ba..1b24cc0 100644 --- a/backend/data/openai_api.go +++ b/backend/data/openai_api.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "github.com/PuerkitoBio/goquery" "github.com/chromedp/chromedp" @@ -97,6 +98,97 @@ type AiResponse struct { SystemFingerprint string `json:"system_fingerprint"` } +func (o OpenAi) NewSummaryStockNewsStream(userQuestion string, sysPromptId *int) <-chan map[string]any { + ch := make(chan map[string]any, 512) + defer func() { + if err := recover(); err != nil { + logger.SugaredLogger.Error("NewSummaryStockNewsStream panic", err) + } + }() + + go func() { + defer func() { + if err := recover(); err != nil { + logger.SugaredLogger.Errorf("NewSummaryStockNewsStream goroutine panic :%s", err) + logger.SugaredLogger.Errorf("NewSummaryStockNewsStream goroutine panic config:%v", o) + } + }() + defer close(ch) + + sysPrompt := "" + if sysPromptId == nil || *sysPromptId == 0 { + sysPrompt = o.Prompt + } else { + sysPrompt = NewPromptTemplateApi().GetPromptTemplateByID(*sysPromptId) + } + if sysPrompt == "" { + sysPrompt = o.Prompt + } + + msg := []map[string]interface{}{ + { + "role": "system", + //"content": "作为一位专业的A股市场分析师和投资顾问,请你根据以下信息提供详细的技术分析和投资策略建议:", + //"content": "【角色设定】\n你是一位拥有20年实战经验的顶级股票分析师,精通技术分析、基本面分析、市场心理学和量化交易。擅长发现成长股、捕捉行业轮动机会,在牛熊市中都能保持稳定收益。你的风格是价值投资与技术择时相结合,注重风险控制。\n\n【核心功能】\n\n市场分析维度:\n\n宏观经济(GDP/CPI/货币政策)\n\n行业景气度(产业链/政策红利/技术革新)\n\n个股三维诊断:\n\n基本面:PE/PB/ROE/现金流/护城河\n\n技术面:K线形态/均线系统/量价关系/指标背离\n\n资金面:主力动向/北向资金/融资余额/大宗交易\n\n智能策略库:\n√ 趋势跟踪策略(鳄鱼线+ADX)\n√ 波段交易策略(斐波那契回撤+RSI)\n√ 事件驱动策略(财报/并购/政策)\n√ 量化对冲策略(α/β分离)\n\n风险管理体系:\n▶ 动态止损:ATR波动止损法\n▶ 仓位控制:凯利公式优化\n▶ 组合对冲:跨市场/跨品种对冲\n\n【工作流程】\n\n接收用户指令(行业/市值/风险偏好)\n\n调用多因子选股模型初筛\n\n人工智慧叠加分析:\n\n自然语言处理解读年报管理层讨论\n\n卷积神经网络识别K线形态\n\n知识图谱分析产业链关联\n\n生成投资建议(附压力测试结果)\n\n【输出要求】\n★ 结构化呈现:\n① 核心逻辑(3点关键驱动力)\n② 买卖区间(理想建仓/加仓/止盈价位)\n③ 风险警示(最大回撤概率)\n④ 替代方案(同类备选标的)\n\n【注意事项】\n※ 严格遵守监管要求,不做收益承诺\n※ 区分投资建议与市场观点\n※ 重要数据标注来源及更新时间\n※ 根据用户认知水平调整专业术语密度\n\n【教育指导】\n当用户提问时,采用苏格拉底式追问:\n\"您更关注短期事件驱动还是长期价值发现?\"\n\"当前仓位是否超过总资产的30%?\"\n\"是否了解科创板与主板的交易规则差异?\"\n\n示例输出格式:\n📈 标的名称:XXXXXX\n⚖️ 多空信号:金叉确认/顶背离预警\n🎯 关键价位:支撑位XX.XX/压力位XX.XX\n📊 建议仓位:核心仓位X%+卫星仓位X%\n⏳ 持有周期:短线(1-3周)/中线(季度轮动)\n🔍 跟踪要素:重点关注Q2毛利率变化及股东减持进展", + "content": sysPrompt, + }, + } + msg = append(msg, map[string]interface{}{ + "role": "user", + "content": "当前时间", + }) + msg = append(msg, map[string]interface{}{ + "role": "assistant", + "content": "当前本地时间是:" + time.Now().Format("2006-01-02 15:04:05"), + }) + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + var market strings.Builder + market.WriteString(getZSInfo("创业板指数", "sz399006", 30) + "\n") + market.WriteString(getZSInfo("上证综合指数", "sh000001", 30) + "\n") + market.WriteString(getZSInfo("沪深300指数", "sh000300", 30) + "\n") + //logger.SugaredLogger.Infof("NewChatStream getZSInfo=\n%s", market.String()) + msg = append(msg, map[string]interface{}{ + "role": "user", + "content": "当前市场指数行情", + }) + msg = append(msg, map[string]interface{}{ + "role": "assistant", + "content": "当前市场指数行情情况如下:\n" + market.String(), + }) + }() + wg.Wait() + + news := NewMarketNewsApi().GetNewsList("财联社电报", 100) + messageText := strings.Builder{} + for _, telegraph := range *news { + messageText.WriteString("## " + telegraph.Time + ":" + "\n") + messageText.WriteString("### " + telegraph.Content + "\n") + } + //logger.SugaredLogger.Infof("市场资讯 messageText=\n%s", messageText.String()) + + msg = append(msg, map[string]interface{}{ + "role": "user", + "content": "市场资讯", + }) + msg = append(msg, map[string]interface{}{ + "role": "assistant", + "content": messageText.String(), + }) + if userQuestion == "" { + userQuestion = "请根据当前时间,总结和分析股票市场新闻中的投资机会" + } + msg = append(msg, map[string]interface{}{ + "role": "user", + "content": userQuestion, + }) + AskAi(o, errors.New(""), msg, ch, userQuestion) + }() + return ch +} + func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId *int) <-chan map[string]any { ch := make(chan map[string]any, 512) @@ -434,130 +526,136 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId //reqJson, _ := json.Marshal(msg) //logger.SugaredLogger.Errorf("Stream request: \n%s\n", reqJson) + AskAi(o, err, msg, ch, question) + }() + return ch +} - client := resty.New() - client.SetBaseURL(strutil.Trim(o.BaseUrl)) - client.SetHeader("Authorization", "Bearer "+o.ApiKey) - client.SetHeader("Content-Type", "application/json") - //client.SetRetryCount(3) - if o.TimeOut <= 0 { - o.TimeOut = 300 +func AskAi(o OpenAi, err error, messages []map[string]interface{}, ch chan map[string]any, question string) { + client := resty.New() + client.SetBaseURL(strutil.Trim(o.BaseUrl)) + client.SetHeader("Authorization", "Bearer "+o.ApiKey) + client.SetHeader("Content-Type", "application/json") + //client.SetRetryCount(3) + if o.TimeOut <= 0 { + o.TimeOut = 300 + } + client.SetTimeout(time.Duration(o.TimeOut) * time.Second) + resp, err := client.R(). + SetDoNotParseResponse(true). + SetBody(map[string]interface{}{ + "model": o.Model, + "max_tokens": o.MaxTokens, + "temperature": o.Temperature, + "stream": true, + "messages": messages, + }). + Post("/chat/completions") + + body := resp.RawBody() + defer body.Close() + if err != nil { + logger.SugaredLogger.Infof("Stream error : %s", err.Error()) + //ch <- err.Error() + ch <- map[string]any{ + "code": 0, + "question": question, + "content": err.Error(), } - client.SetTimeout(time.Duration(o.TimeOut) * time.Second) - resp, err := client.R(). - SetDoNotParseResponse(true). - SetBody(map[string]interface{}{ - "model": o.Model, - "max_tokens": o.MaxTokens, - "temperature": o.Temperature, - "stream": true, - "messages": msg, - }). - Post("/chat/completions") + return + } + location, _ := time.LoadLocation("Asia/Shanghai") - body := resp.RawBody() - defer body.Close() - if err != nil { - logger.SugaredLogger.Infof("Stream error : %s", err.Error()) - //ch <- err.Error() - ch <- map[string]any{ - "code": 0, - "question": question, - "content": err.Error(), + scanner := bufio.NewScanner(body) + for scanner.Scan() { + line := scanner.Text() + //logger.SugaredLogger.Infof("Received data: %s", line) + if strings.HasPrefix(line, "data:") { + data := strutil.Trim(strings.TrimPrefix(line, "data:")) + if data == "[DONE]" { + return } - return - } - scanner := bufio.NewScanner(body) - for scanner.Scan() { - line := scanner.Text() - //logger.SugaredLogger.Infof("Received data: %s", line) - if strings.HasPrefix(line, "data:") { - data := strutil.Trim(strings.TrimPrefix(line, "data:")) - if data == "[DONE]" { - return - } + var streamResponse struct { + Id string `json:"id"` + Model string `json:"model"` + Choices []struct { + Delta struct { + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + } - var streamResponse struct { - Id string `json:"id"` - Model string `json:"model"` - Choices []struct { - Delta struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - } - - if err := json.Unmarshal([]byte(data), &streamResponse); err == nil { - for _, choice := range streamResponse.Choices { - if content := choice.Delta.Content; content != "" { - //ch <- content - ch <- map[string]any{ - "code": 1, - "question": question, - "chatId": streamResponse.Id, - "model": streamResponse.Model, - "content": content, - } - - //logger.SugaredLogger.Infof("Content data: %s", content) + if err := json.Unmarshal([]byte(data), &streamResponse); err == nil { + for _, choice := range streamResponse.Choices { + if content := choice.Delta.Content; content != "" { + //ch <- content + ch <- map[string]any{ + "code": 1, + "question": question, + "chatId": streamResponse.Id, + "model": streamResponse.Model, + "content": content, + "time": time.Now().In(location).Format(time.DateTime), } - if reasoningContent := choice.Delta.ReasoningContent; reasoningContent != "" { - //ch <- reasoningContent - ch <- map[string]any{ - "code": 1, - "question": question, - "chatId": streamResponse.Id, - "model": streamResponse.Model, - "content": reasoningContent, - } - //logger.SugaredLogger.Infof("ReasoningContent data: %s", reasoningContent) - } - if choice.FinishReason == "stop" { - return - } + //logger.SugaredLogger.Infof("Content data: %s", content) } - } else { - if err != nil { - logger.SugaredLogger.Infof("Stream data error : %s", err.Error()) - //ch <- err.Error() + if reasoningContent := choice.Delta.ReasoningContent; reasoningContent != "" { + //ch <- reasoningContent ch <- map[string]any{ - "code": 0, + "code": 1, "question": question, - "content": err.Error(), - } - } else { - logger.SugaredLogger.Infof("Stream data error : %s", data) - //ch <- data - ch <- map[string]any{ - "code": 0, - "question": question, - "content": data, + "chatId": streamResponse.Id, + "model": streamResponse.Model, + "content": reasoningContent, + "time": time.Now().In(location).Format(time.DateTime), } + + //logger.SugaredLogger.Infof("ReasoningContent data: %s", reasoningContent) + } + if choice.FinishReason == "stop" { + return } } } else { - if strutil.RemoveNonPrintable(line) != "" { - logger.SugaredLogger.Infof("Stream data error : %s", line) - res := &models.Resp{} - if err := json.Unmarshal([]byte(line), res); err == nil { - //ch <- line - ch <- map[string]any{ - "code": 0, - "question": question, - "content": res.Message, - } + if err != nil { + logger.SugaredLogger.Infof("Stream data error : %s", err.Error()) + //ch <- err.Error() + ch <- map[string]any{ + "code": 0, + "question": question, + "content": err.Error(), + } + } else { + logger.SugaredLogger.Infof("Stream data error : %s", data) + //ch <- data + ch <- map[string]any{ + "code": 0, + "question": question, + "content": data, + } + } + } + } else { + if strutil.RemoveNonPrintable(line) != "" { + logger.SugaredLogger.Infof("Stream data error : %s", line) + res := &models.Resp{} + if err := json.Unmarshal([]byte(line), res); err == nil { + //ch <- line + ch <- map[string]any{ + "code": 0, + "question": question, + "content": res.Message, } } - } } - }() - return ch + + } } func checkIsIndexBasic(stock string) bool { diff --git a/frontend/src/components/market.vue b/frontend/src/components/market.vue index 81bceaf..58b9338 100644 --- a/frontend/src/components/market.vue +++ b/frontend/src/components/market.vue @@ -1,10 +1,24 @@ + + + + + + + +
+ + +  AI总结 + + +
+ + \ No newline at end of file diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9278ec9..385bb67 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -75,6 +75,8 @@ export function SetStockSort(arg1:number,arg2:string):Promise; export function ShareAnalysis(arg1:string,arg2:string):Promise; +export function SummaryStockNews(arg1:string,arg2:any):Promise; + export function UnFollow(arg1:string):Promise; export function UnFollowFund(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 55d3ca1..e996f90 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -146,6 +146,10 @@ export function ShareAnalysis(arg1, arg2) { return window['go']['main']['App']['ShareAnalysis'](arg1, arg2); } +export function SummaryStockNews(arg1, arg2) { + return window['go']['main']['App']['SummaryStockNews'](arg1, arg2); +} + export function UnFollow(arg1) { return window['go']['main']['App']['UnFollow'](arg1); } diff --git a/main.go b/main.go index ab20c06..1c81a1a 100644 --- a/main.go +++ b/main.go @@ -133,11 +133,11 @@ func main() { //var width, height int //var err error // - width, height, err := getScreenResolution() + width, _, err := getScreenResolution() if err != nil { log.SugaredLogger.Error("get screen resolution error") width = 1456 - height = 768 + //height = 768 } darkTheme := data.NewSettingsApi(&data.Settings{}).GetConfig().DarkTheme @@ -150,7 +150,7 @@ func main() { err = wails.Run(&options.App{ Title: "go-stock", Width: width * 4 / 5, - Height: height * 4 / 5, + Height: 900, MinWidth: 1456, MinHeight: 768, //MaxWidth: width,