feat(frontend):添加AI市场资讯总结功能

- 在市场组件中增加 AI 总结按钮和模态框
- 实现 SummaryStockNews 函数用于获取 AI 总结
- 添加 GetNewsList 方法获取市场新闻列表
- 优化市场资讯的展示和交互
This commit is contained in:
ArvinLovegood 2025-04-25 16:33:14 +08:00
parent cedff896bb
commit 3535ba57ab
7 changed files with 430 additions and 114 deletions

8
app.go
View File

@ -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")
}

View File

@ -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 != "" {

View File

@ -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,7 +526,12 @@ 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
}
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)
@ -451,7 +548,7 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId
"max_tokens": o.MaxTokens,
"temperature": o.Temperature,
"stream": true,
"messages": msg,
"messages": messages,
}).
Post("/chat/completions")
@ -467,6 +564,7 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId
}
return
}
location, _ := time.LoadLocation("Asia/Shanghai")
scanner := bufio.NewScanner(body)
for scanner.Scan() {
@ -500,6 +598,7 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId
"chatId": streamResponse.Id,
"model": streamResponse.Model,
"content": content,
"time": time.Now().In(location).Format(time.DateTime),
}
//logger.SugaredLogger.Infof("Content data: %s", content)
@ -512,6 +611,7 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId
"chatId": streamResponse.Id,
"model": streamResponse.Model,
"content": reasoningContent,
"time": time.Now().In(location).Format(time.DateTime),
}
//logger.SugaredLogger.Infof("ReasoningContent data: %s", reasoningContent)
@ -556,8 +656,6 @@ func (o OpenAi) NewChatStream(stock, stockCode, userQuestion string, sysPromptId
}
}
}()
return ch
}
func checkIsIndexBasic(stock string) bool {

View File

@ -1,10 +1,24 @@
<script setup>
import {onBeforeMount, ref} from 'vue'
import {GetTelegraphList, GlobalStockIndexes} from "../../wailsjs/go/main/App";
import {computed, h, onBeforeMount, ref} from 'vue'
import {
GetAIResponseResult,
GetConfig, GetPromptTemplates,
GetTelegraphList,
GlobalStockIndexes,
SaveAIResponseResult, SaveAsMarkdown, ShareAnalysis,
SummaryStockNews
} from "../../wailsjs/go/main/App";
import {EventsOn} from "../../wailsjs/runtime";
import NewsList from "./newsList.vue";
import KLineChart from "./KLineChart.vue";
import {Add, ChatboxOutline, PulseOutline,} from "@vicons/ionicons5";
import {NAvatar, NButton, NFlex, NText, useMessage, useNotification} from "naive-ui";
import {ExportPDF} from "@vavt/v3-extension";
import {MdEditor, MdPreview} from "md-editor-v3";
const icon = ref('https://raw.githubusercontent.com/ArvinLovegood/go-stock/master/build/appicon.png');
const message = useMessage()
const notify = useNotification()
const panelHeight = ref(window.innerHeight - 240)
const telegraphList = ref([])
@ -16,6 +30,22 @@ const europe = ref([])
const asia = ref([])
const other = ref([])
const globalStockIndexes = ref(null)
const summaryModal= ref(false)
const summaryBTN= ref(true)
const darkTheme= ref(false)
const theme=computed(() => {
return darkTheme ? 'dark' : 'light'
})
const aiSummary=ref(``)
const aiSummaryTime=ref("")
const modelName=ref("")
const chatId=ref("")
const question=ref(``)
const sysPromptId=ref(0)
const loading=ref(true)
const sysPromptOptions=ref([])
const userPromptOptions=ref([])
const promptTemplates=ref([])
function getIndex() {
GlobalStockIndexes().then((res) => {
@ -25,11 +55,20 @@ function getIndex() {
europe.value = res["europe"]
asia.value = res["asia"]
other.value = res["other"]
console.log(globalStockIndexes.value)
})
}
onBeforeMount(() => {
GetConfig().then(result => {
summaryBTN.value= result.openAiEnable
darkTheme.value = result.darkTheme
})
GetPromptTemplates("","").then(res=>{
promptTemplates.value=res
sysPromptOptions.value=promptTemplates.value.filter(item => item.type === '模型系统Prompt')
userPromptOptions.value=promptTemplates.value.filter(item => item.type === '模型用户Prompt')
})
GetTelegraphList("财联社电报").then((res) => {
telegraphList.value = res
})
@ -77,11 +116,114 @@ function getAreaName(code){
return "其他"
}
}
function reAiSummary(){
aiSummary.value=""
summaryModal.value = true
loading.value = true
SummaryStockNews(question.value,sysPromptId.value)
}
function getAiSummary(){
summaryModal.value = true
loading.value = true
GetAIResponseResult("市场资讯").then(result => {
if(result.content){
aiSummary.value=result.content
question.value=result.question
loading.value = false
const date = new Date(result.CreatedAt);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
aiSummaryTime.value=`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
modelName.value=result.modelName
}else{
aiSummaryTime.value=""
aiSummary.value=""
modelName.value=""
SummaryStockNews(question.value,sysPromptId.value)
}
})
}
function updateTab(name) {
summaryBTN.value = (name === "市场快讯");
}
EventsOn("summaryStockNews",async (msg) => {
loading.value = false
////console.log(msg)
if (msg === "DONE") {
SaveAIResponseResult("市场资讯","市场资讯", aiSummary.value, chatId.value,question.value)
message.info("AI分析完成")
message.destroyAll()
} else {
if(msg.chatId){
chatId.value = msg.chatId
}
if(msg.question){
question.value = msg.question
}
if(msg.content){
aiSummary.value =aiSummary.value + msg.content
}
if(msg.extraContent){
aiSummary.value = aiSummary.value + msg.extraContent
}
if(msg.model){
modelName.value=msg.model
}
if(msg.time){
aiSummaryTime.value = msg.time
}
}
})
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(aiSummary.value);
message.success('分析结果已复制到剪切板');
} catch (err) {
message.error('复制失败: ' + err);
}
}
function saveAsMarkdown(){
SaveAsMarkdown('','市场资讯').then(result => {
message.success(result)
})
}
function share(){
ShareAnalysis('市场资讯','市场资讯').then(msg => {
//message.info(msg)
notify.info({
avatar: () =>
h(NAvatar, {
size: 'small',
round: false,
src: icon.value
}),
title: '分享到社区',
duration:1000*30,
content: () => {
return h('div', {
style: {
'text-align': 'left',
'font-size': '14px',
}
}, { default: () => msg })
},
})
})
}
</script>
<template>
<n-card>
<n-tabs type="line" animated>
<n-tabs type="line" animated @update-value="updateTab">
<n-tab-pane name="市场快讯" tab="市场快讯" >
<n-grid :cols="2" :y-gap="0">
<n-gi>
@ -188,8 +330,51 @@ function getAreaName(code){
</n-tab-pane>
</n-tabs>
</n-card>
<n-modal transform-origin="center" v-model:show="summaryModal" preset="card" style="width: 800px;" :title="'AI市场资讯总结'" >
<n-spin size="small" :show="loading">
<MdPreview style="height: 440px;text-align: left" :modelValue="aiSummary" :theme="theme"/>
</n-spin>
<template #footer>
<n-flex justify="space-between" ref="tipsRef">
<n-text type="info" v-if="aiSummaryTime" >
<n-tag v-if="modelName" type="warning" round :title="chatId" :bordered="false">{{modelName}}</n-tag>
{{aiSummaryTime}}
</n-text>
<n-text type="error" >*AI分析结果仅供参考请以实际行情为准投资需谨慎风险自担</n-text>
</n-flex>
</template>
<template #action>
<n-flex justify="space-between" style="margin-bottom: 10px">
<n-select style="width: 49%" v-model:value="sysPromptId" label-field="name" value-field="ID" :options="sysPromptOptions" placeholder="请选择系统提示词" />
<n-select style="width: 49%" v-model:value="question" label-field="name" value-field="content" :options="userPromptOptions" placeholder="请选择用户提示词" />
</n-flex>
<n-flex justify="right">
<n-input v-model:value="question" style="text-align: left" clearable
type="textarea"
:show-count="true"
placeholder="请输入您的问题:例如 总结和分析股票市场新闻中的投资机会"
:autosize="{
minRows: 2,
maxRows: 5
}"
/>
<n-button size="tiny" type="warning" @click="reAiSummary">再次总结</n-button>
<n-button size="tiny" type="success" @click="copyToClipboard">复制到剪切板</n-button>
<n-button size="tiny" type="primary" @click="saveAsMarkdown">保存为Markdown文件</n-button>
<n-button size="tiny" type="error" @click="share">分享到项目社区</n-button>
</n-flex>
</template>
</n-modal>
<div style="position: fixed;bottom: 18px;right:25px;z-index: 10;" v-if="summaryBTN">
<n-input-group >
<n-button type="primary" @click="getAiSummary">
<n-icon :component="PulseOutline"/> &nbsp;AI总结
</n-button>
</n-input-group>
</div>
</template>
<style scoped>
</style>

View File

@ -75,6 +75,8 @@ export function SetStockSort(arg1:number,arg2:string):Promise<void>;
export function ShareAnalysis(arg1:string,arg2:string):Promise<string>;
export function SummaryStockNews(arg1:string,arg2:any):Promise<void>;
export function UnFollow(arg1:string):Promise<string>;
export function UnFollowFund(arg1:string):Promise<string>;

View File

@ -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);
}

View File

@ -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,