Compare commits

..

2 Commits

Author SHA1 Message Date
fd0aebcbaa feat 存档 2026-03-21 21:44:24 +08:00
86c63292b2 feat 存档 2026-03-21 20:21:37 +08:00
22 changed files with 1101 additions and 51 deletions

View File

@@ -18,18 +18,21 @@ func NewAIClient(enableSearch bool, searchStrategy string, temperature float32)
}
}
func (c *Client) RequestAI(pre string, prompt []string, cb func(content string, i int) error) error {
func (c *Client) RequestAI(pre []string, prompt []string, cb func(content string, i int) error) ([]Message, error) {
inToken, outToken := int32(0), int32(0)
messages := []Message{{
Role: "user",
Content: pre,
}}
messages := make([]Message, 0)
for _, s := range pre {
messages = append(messages, Message{
Role: "user",
Content: s,
})
}
for i, step := range prompt {
messages = append(messages, Message{
Role: "user",
Content: step,
})
r, err := request("qwen3-max", Parameters{
r, err := request("qwen-plus", Parameters{
//r, err := request("deepseek-v3.2", Parameters{
EnableSearch: c.enableSearch,
SearchOptions: SearchOptions{
@@ -39,21 +42,21 @@ func (c *Client) RequestAI(pre string, prompt []string, cb func(content string,
Temperature: c.temperature,
}, messages)
if err != nil {
return err
return nil, err
}
inToken += r.Usage.InputTokens
outToken += r.Usage.OutputTokens
if len(r.Output.Choices) == 0 {
fmt.Printf("AI response err, r: %#+v\n", r)
return err
fmt.Printf("AI response err: %+v\n", r)
return nil, err
} else {
messages = append(messages, r.Output.Choices[0].Message)
}
fmt.Printf("AI 回答 %v: %v\n", i, r.Output.Choices[0].Message.Content)
if err = cb(r.Output.Choices[0].Message.Content, i); err != nil {
return err
return nil, err
}
}
fmt.Printf("AI finished, use token: in-%v out-%v\n", inToken, outToken)
return nil
return messages, nil
}

View File

@@ -30,14 +30,19 @@ type SearchOptions struct {
type RequestBody struct {
Model string `json:"model"`
Input Input `json:"input"`
Parameters Parameters `json:"parameters"`
Parameters Parameters `json:"parameters,omitempty"`
}
type ResponseBody struct {
Output struct {
Choices []struct {
Message Message `json:"message"`
StatusCode string `json:"status_code"`
RequestID string `json:"request_id"`
Code string `json:"code"`
Output struct {
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
Choices []struct {
FinishReason string `json:"finish_reason"`
Message Message `json:"message"`
} `json:"choices"`
} `json:"output"`
Usage struct {

View File

@@ -4,9 +4,16 @@ import (
"context"
"encoding/json"
"fiction/backend/ai"
"fiction/backend/doubao"
"fiction/db/model"
"fiction/db/sqlite"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
)
// App struct
@@ -21,6 +28,14 @@ func NewApp() *App {
func (a *App) Startup(ctx context.Context) {
a.ctx = ctx
if err := sqlite.Init("./db/db.db"); err != nil {
panic(err)
}
}
func (a *App) Shutdown(ctx context.Context) {
a.ctx = nil
sqlite.Stop()
}
// GetNovelList returns the list of txt files in the source directory
@@ -57,33 +72,173 @@ func (a *App) GetNovelContent(filename string) string {
return string(content)
}
func (a *App) WriteNovelContent(filename string) string {
filePath := filepath.Join("./config", "config.json")
// GetFangWenContent 获取仿文的内容
func (a *App) GetFangWenContent(filename string) string {
first := &model.List{}
sqlite.DB.Model(&model.List{}).
Where("name = ?", filename).
First(&first)
if first.ID > 0 {
if len(first.Imitation) > 0 {
return first.Imitation
}
if first.Status == 1 {
return "正在仿写中..."
}
}
return ""
}
content, err := os.ReadFile(filePath)
// GetAIContent 获取ai对话记录
func (a *App) GetAIContent(filename string) []ai.Message {
first := &model.List{}
sqlite.DB.Model(&model.List{}).
Where("name = ?", filename).
First(&first)
if first.ID > 0 {
if len(first.AI) > 0 {
resp := make([]ai.Message, 0)
_ = json.Unmarshal([]byte(first.AI), &resp)
return resp
}
}
return make([]ai.Message, 0)
}
// GetToken 获取ai token消耗
func (a *App) GetToken(filename string) string {
first := &model.List{}
sqlite.DB.Model(&model.List{}).
Where("name = ?", filename).
First(&first)
return first.Token
}
func (a *App) WriteNovelContent(filename string) string {
content, err := os.ReadFile(filepath.Join("./config", "config.json"))
if err != nil {
fmt.Printf("读取提示词文件错误: %v\n", err)
return "Error reading file"
}
var str []string
if err = json.Unmarshal(content, &str); err != nil {
fmt.Printf("读取提示词错误: %v\n", err)
return "Error Unmarshal JSON"
}
resp := ""
err = ai.NewAIClient(false, "", 0.9).
first := &model.List{}
sqlite.DB.Model(&model.List{}).
Where("name = ?", filename).
First(&first)
if first.ID == 0 {
first.Name = filename
first.Status = 1
sqlite.DB.Create(&first)
}
resp := make([]string, 0)
msgList, in, out, err := doubao.NewAIClient(false, "", 0).
RequestAI(
a.GetNovelContent(filename),
a.GetModelConfig().Model,
a.GetModelConfig().Key,
[]string{
"本次对话是通过API的方式对话,所以请你不要在回答中掺杂各种图片、修正格式的符号等等,仅以纯文本+换行符的方式回答",
a.GetNovelContent(filename),
},
str,
func(content string, i int) error {
resp = content
processedContent := strings.ReplaceAll(content, "\n\n", "\n")
resp = append(resp, processedContent)
return nil
},
)
if err != nil {
return err.Error()
}
return resp
marshal, _ := json.Marshal(msgList)
first.Name = filename
first.Imitation = resp[len(resp)-1]
first.AI = string(marshal)
first.Status = 2
first.Token = fmt.Sprintf("模型:%v,输入 %d Token,输出 %d Token,预计费用:%.4f元", a.GetModelConfig().Model, in, out, a.GetModelConfig().In/1000000*float64(in)+a.GetModelConfig().Out/1000000*float64(out))
sqlite.DB.Save(first)
saveToFile(filename, resp[len(resp)-1])
return resp[len(resp)-1]
}
func saveToFile(name string, content string) {
// 获取当前时间戳(精确到秒)
timestamp := time.Now().Format("01-02 15-04-05") // 格式化为 YYYYMMDDHHMMSS
// 构建输出文件名:原文件名 + 时间戳 + .txt
baseName := strings.TrimSuffix(name, filepath.Ext(name))
outputFileName := fmt.Sprintf("%s_%s.txt", baseName, timestamp)
outputPath := filepath.Join("./generate", outputFileName)
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
fmt.Printf("写入文件失败: %v\n", err)
}
}
// OpenSourceDir 打开原文目录
func (a *App) OpenSourceDir() error {
// 获取当前工作目录
cwd, err := os.Getwd()
if err != nil {
return err
}
// 构建绝对路径
sourceDir := filepath.Join(cwd, "source")
// 确保目录存在
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
os.MkdirAll(sourceDir, 0755)
}
// 打开目录
return openDir(sourceDir)
}
// OpenGenerateDir 打开仿文目录
func (a *App) OpenGenerateDir() error {
// 获取当前工作目录
cwd, err := os.Getwd()
if err != nil {
return err
}
// 构建绝对路径
generateDir := filepath.Join(cwd, "generate")
// 确保目录存在
if _, err := os.Stat(generateDir); os.IsNotExist(err) {
os.MkdirAll(generateDir, 0755)
}
// 打开目录
return openDir(generateDir)
}
// openDir 打开指定目录
func openDir(dir string) error {
var cmd string
var args []string
// 根据操作系统选择打开命令
switch os := runtime.GOOS; os {
case "windows":
cmd = "explorer"
args = []string{dir}
case "darwin":
cmd = "open"
args = []string{dir}
case "linux":
cmd = "xdg-open"
args = []string{dir}
default:
return fmt.Errorf("unsupported operating system: %s", os)
}
// 执行命令
return exec.Command(cmd, args...).Start()
}

38
backend/call_word.go Normal file
View File

@@ -0,0 +1,38 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
func (a *App) GetCallWord() []string {
content, err := os.ReadFile(filepath.Join("./config", "config.json"))
if err != nil {
fmt.Printf("读取提示词文件错误: %v\n", err)
return make([]string, 0)
}
var str []string
if err = json.Unmarshal(content, &str); err != nil {
fmt.Printf("读取提示词错误: %v\n", err)
return make([]string, 0)
}
return str
}
func (a *App) SetCallWord(callWord []string) {
data, err := json.Marshal(callWord)
if err != nil {
fmt.Printf("序列化提示词失败: %v\n", err)
return
}
// 写入配置文件
configPath := filepath.Join("./config", "config.json")
if err := os.WriteFile(configPath, data, 0644); err != nil {
fmt.Printf("写入配置文件失败: %v\n", err)
return
}
}

View File

@@ -0,0 +1,54 @@
package doubao
import (
"fmt"
)
type Client struct {
enableSearch bool // 是否开启搜索
searchStrategy string // 搜索策略(开了搜索才生效)
temperature float32 // 采样温度
}
func NewAIClient(enableSearch bool, searchStrategy string, temperature float32) *Client {
return &Client{
enableSearch: enableSearch,
searchStrategy: searchStrategy,
temperature: temperature,
}
}
func (c *Client) RequestAI(model, key string, pre []string, prompt []string, cb func(content string, i int) error) ([]Message, int32, int32, error) {
inToken, outToken := int32(0), int32(0)
messages := make([]Message, 0)
for _, s := range pre {
messages = append(messages, Message{
Role: "user",
Content: s,
})
}
for i, step := range prompt {
messages = append(messages, Message{
Role: "user",
Content: step,
})
r, err := request(model, key, messages)
if err != nil {
return nil, 0, 0, err
}
inToken += r.Usage.InputTokens
outToken += r.Usage.OutputTokens
if len(r.Choices) == 0 {
fmt.Printf("AI response err: %+v\n", r)
return nil, 0, 0, err
} else {
messages = append(messages, r.Choices[0].Message)
}
fmt.Printf("AI 回答 %v: %v\n", i, r.Choices[0].Message.Content)
if err = cb(r.Choices[0].Message.Content, i); err != nil {
return nil, 0, 0, err
}
}
fmt.Printf("AI finished, use token: in-%v out-%v\n", inToken, outToken)
return messages, inToken, outToken, nil
}

View File

@@ -0,0 +1,67 @@
package doubao
import (
"bytes"
"encoding/json"
"io"
"net/http"
)
type RequestBody struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxCompletionTokens int32 `json:"max_completion_tokens"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type SearchOptions struct {
SearchStrategy string `json:"search_strategy"`
}
type ResponseBody struct {
Choices []struct {
FinishReason string `json:"finish_reason"`
Message Message `json:"message"`
} `json:"choices"`
Usage struct {
InputTokens int32 `json:"prompt_tokens"`
OutputTokens int32 `json:"completion_tokens"`
TotalTokens int32 `json:"total_tokens"`
} `json:"usage"`
}
func request(model, key string, messages []Message) (*ResponseBody, error) {
requestBody := RequestBody{
Model: model,
Messages: messages,
MaxCompletionTokens: 65535,
}
jsonData, _ := json.Marshal(requestBody)
req, err := http.NewRequest("POST", "https://ark.cn-beijing.volces.com/api/v3/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{}).Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var res ResponseBody
if err = json.Unmarshal(body, &res); err != nil {
return nil, err
}
return &res, nil
}

43
backend/model.go Normal file
View File

@@ -0,0 +1,43 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type ModelConfig struct {
Key string `json:"key"`
Model string `json:"model"`
In float64 `json:"in"`
Out float64 `json:"out"`
}
func (a *App) GetModelConfig() *ModelConfig {
content, err := os.ReadFile(filepath.Join("./config", "model.json"))
if err != nil {
return &ModelConfig{}
}
resp := &ModelConfig{}
if err = json.Unmarshal(content, &resp); err != nil {
return &ModelConfig{}
}
return resp
}
func (a *App) SetModelConfig(req *ModelConfig) {
data, err := json.Marshal(req)
if err != nil {
fmt.Printf("保存模型配置失败: %v\n", err)
return
}
// 写入配置文件
configPath := filepath.Join("./config", "model.json")
if err := os.WriteFile(configPath, data, 0644); err != nil {
fmt.Printf("保存模型配置失败: %v\n", err)
return
}
}

View File

@@ -1,5 +1 @@
[
"小说识别,识别出的仿写小说的信息,具体识别内容:\n(1)识别并记住原文的核心论点、支撑论据、风格特点及其独特的表达方式。(2)识别并记住原文的结构布局。",
"第2步 - 提供几个同类型的仿写主题,引导用户选择仿写的主题。本步骤完成后,询问用户提供信息是否 ” 满意,如果用户反馈 ” “ 满 意 ” 直接可以进入下一步,如果用户反馈 “ 不满意,则根据用户要求修改。",
"第3步,\n满意,仿写最推荐的主题。\n文章重塑 - 任务:根据新小说的主题,创造性地重新表达原文的核心观点和信息,避免直接抄袭。本步骤完成后,询问用户提供信息是否 ” 满意,如果用户反馈 ” ” 满意 ” 直接可以进入下一步,如果 用户反馈 “ 不满意,则根据用户要求修改。 ” \n要求:\n(1)仿写的小说符合抖音短篇小说风格:开篇炸点、简单粗暴、节奏快、情绪强等。\n(2)全文要有逻辑性、口语化、大白话、短句多、通俗易懂。\n(3)一句为一段。\n(4)模仿原小说60%的写作风格和语气、语法、拼写,同时确保内容的原创性和流畅性。 \n(5)仿写后的小说字数必须在1.2万字左右。(6)只输出小说内容,不要输出其他任何多余的话,内容不要写行号"
]
["你是一个短篇小说家,这篇是你之前写出的小说,请仔细阅读,并识别小说的信息。具体识别内容: 1.识别并记住原文的核心论点、支撑论据、风格特点及其独特的表达方式。 2.识别并记住原文的结构布局。","我需要对本篇小说进行仿写,请思考并提供几个同类型的仿写主题","请直接根据你最推荐的主题,完整仿写一篇小说,要求如下: 1.根据新小说的主题,创造性地重新表达原文的核心观点和信息,避免直接抄袭。 2.仿写的小说符合抖音短篇小说风格:开篇炸点、简单粗暴、节奏快、情绪强等。 3.全文要有逻辑性、口语化、大白话、短句多(尽量10个字以上,内容丰满)、通俗易懂。 4.模仿原小说60%的写作风格和语气、语法、拼写,同时确保内容的原创性和流畅性。 5.仿写后的小说字数必须在1.2万字左右(非常重要)。 6.只输出小说内容,不要输出其他任何多余的话,内容不要写行号"]

1
config/model.json Normal file
View File

@@ -0,0 +1 @@
{"key":"7edc03ad-d2c9-4dfe-85ad-2860a77123ae","model":"doubao-seed-2-0-pro-260215","in":3.2,"out":16}

BIN
db/db.db Normal file

Binary file not shown.

14
db/model/user.go Normal file
View File

@@ -0,0 +1,14 @@
package model
type List struct {
ID int32 `json:"id" gorm:"column:id;primaryKey"` // 主键
Name string `json:"name" gorm:"column:name;uniqueIndex"` // 小说名称
Status int32 `json:"status" gorm:"column:status"` // 状态 0 未知 1 仿写中 2 仿写完成
Imitation string `json:"imitation" gorm:"column:imitation"` // 仿文
AI string `json:"ai" gorm:"column:ai"` // AI对话记录
Token string `json:"token" gorm:"column:token"` // Token消耗
}
func (*List) TableName() string {
return "list"
}

51
db/sqlite/sqlite.go Normal file
View File

@@ -0,0 +1,51 @@
package sqlite
import (
"fiction/db/model"
"fmt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"log"
"time"
)
var DB *gorm.DB
func Init(dsn string) error {
fmt.Println("init SQLite")
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
TranslateError: true,
Logger: logger.New(
log.New(log.Writer(), "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 10 * time.Millisecond,
Colorful: true,
LogLevel: logger.Info,
},
),
})
if err != nil {
return err
}
DB = db
if err = initDB(); err != nil {
return err
}
return nil
}
func initDB() error {
tableArr := []interface{}{
&model.List{},
}
for _, table := range tableArr {
if err := DB.AutoMigrate(table); err != nil {
return err
}
}
return nil
}
func Stop() {
}

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, watch } from 'vue'
import Sidebar from './components/Sidebar.vue'
import { GetNovelContent, WriteNovelContent } from '../wailsjs/go/backend/App'
import { GetNovelContent, WriteNovelContent, GetFangWenContent, GetAIContent, GetToken } from '../wailsjs/go/backend/App'
const activeButton = ref('')
const content = ref('请选择要查看的小说')
@@ -9,14 +9,43 @@ const currentTitle = ref('小说内容')
const viewMode = ref('original')
const currentFilename = ref('')
const isLoading = ref(false)
const aiMessages = ref([])
const tokenInfo = ref('')
// 监听 viewMode 变化
watch(viewMode, (newMode) => {
if (newMode === 'imitation') {
content.value = currentFilename.value + ' 的仿写内容'
watch(viewMode, async (newMode) => {
if (newMode === 'imitation' && currentFilename.value) {
isLoading.value = true
try {
const fangWenContent = await GetFangWenContent(currentFilename.value)
if (fangWenContent) {
content.value = fangWenContent
} else {
content.value = currentFilename.value + ' 的仿写内容'
}
// 获取 token 信息
const token = await GetToken(currentFilename.value)
tokenInfo.value = token
} catch (error) {
console.error('Error getting fangwen content:', error)
content.value = currentFilename.value + ' 的仿写内容'
} finally {
isLoading.value = false
}
} else if (newMode === 'original' && currentFilename.value) {
// 切换回原文模式时,重新加载原文
handleMenuClick({ id: activeButton.value, filename: currentFilename.value })
} else if (newMode === 'ai' && currentFilename.value) {
isLoading.value = true
try {
const aiContent = await GetAIContent(currentFilename.value)
aiMessages.value = aiContent
} catch (error) {
console.error('Error getting AI content:', error)
aiMessages.value = []
} finally {
isLoading.value = false
}
}
})
@@ -53,6 +82,9 @@ async function handleImitation() {
try {
const imitationContent = await WriteNovelContent(currentFilename.value)
content.value = imitationContent
// 更新 token 信息
const token = await GetToken(currentFilename.value)
tokenInfo.value = token
} catch (error) {
console.error('Error generating imitation content:', error)
content.value = '生成仿写内容失败'
@@ -63,6 +95,13 @@ async function handleImitation() {
content.value = '请先选择要仿写的小说'
}
}
// 计算去掉换行符后的字数
function countCharacters(text) {
if (!text) return 0
// 去掉所有换行符,然后统计长度
return text.replace(/\n/g, '').length
}
</script>
<template>
@@ -87,11 +126,22 @@ async function handleImitation() {
>
仿文
</el-button>
<el-button
:type="viewMode === 'ai' ? 'primary' : 'default'"
@click="viewMode = 'ai'"
>
AI对话记录
</el-button>
</div>
</div>
<div class="content-body">
<div v-if="viewMode === 'original'">
{{ content }}
<div v-if="viewMode === 'original'" class="content-wrapper">
<div class="content-scrollable">
{{ content }}
</div>
<div class="content-footer">
字数: {{ countCharacters(content) }}
</div>
</div>
<div v-else-if="viewMode === 'imitation'" class="imitation-container">
<div v-if="!isLoading && (content === '请选择要查看的小说' || content === '正在生成仿写内容...' || content === currentFilename + ' 的仿写内容')" class="imitation-button-container">
@@ -106,12 +156,42 @@ async function handleImitation() {
仿写
</el-button>
</div>
<div v-else class="imitation-content">
{{ content }}
<div v-else class="content-wrapper">
<div class="content-scrollable">
{{ content }}
</div>
<div class="content-footer">
<span class="token-info">{{ tokenInfo }}</span>
<span class="word-count">字数: {{ countCharacters(content) }}</span>
</div>
</div>
</div>
<div v-else-if="viewMode === 'ai'" class="content-wrapper">
<div class="content-scrollable ai-chat-container">
<div
v-for="(message, index) in aiMessages"
:key="index"
:class="['ai-chat-message', message.role === 'user' ? 'user-message' : 'ai-message']"
>
<div class="message-content">
{{ message.content }}
</div>
</div>
<div v-if="aiMessages.length === 0" class="empty-ai-messages">
暂无AI对话记录
</div>
</div>
<div class="content-footer">
对话条数: {{ aiMessages.length }}
</div>
</div>
<div v-else>
{{ content }}
<div v-else class="content-wrapper">
<div class="content-scrollable">
{{ content }}
</div>
<div class="content-footer">
字数: {{ countCharacters(content) }}
</div>
</div>
</div>
</div>
@@ -172,18 +252,78 @@ body {
padding: 20px;
color: #ecf0f1;
min-height: 400px;
display: flex;
flex-direction: column;
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.content-scrollable {
flex: 1;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
padding-right: 20px;
text-align: left;
min-height: 0;
}
.content-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
display: flex;
justify-content: space-between;
align-items: center;
}
.token-info {
text-align: left;
}
.word-count {
text-align: right;
}
.content-scrollable::-webkit-scrollbar {
width: 8px;
}
.content-scrollable::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
.content-scrollable::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.content-scrollable::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.imitation-container {
height: 100%;
padding: 20px;
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
min-height: 0;
}
.imitation-button-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
min-height: 0;
}
.imitation-button {
@@ -191,10 +331,42 @@ body {
font-size: 18px;
}
.imitation-content {
height: 100%;
overflow-y: auto;
white-space: pre-wrap;
.ai-chat-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-chat-message {
max-width: 80%;
padding: 10px 15px;
border-radius: 18px;
margin-bottom: 10px;
}
.user-message {
align-self: flex-end;
background-color: #3498db;
color: white;
border-bottom-right-radius: 4px;
}
.ai-message {
align-self: flex-start;
background-color: rgba(255, 255, 255, 0.1);
color: #ecf0f1;
border-bottom-left-radius: 4px;
}
.message-content {
word-break: break-word;
white-space: pre-wrap;
}
.empty-ai-messages {
text-align: center;
color: rgba(255, 255, 255, 0.5);
padding: 40px 0;
font-style: italic;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { Document } from '@element-plus/icons-vue'
import { GetNovelList } from '../../wailsjs/go/backend/App'
import { ref, onMounted, onUnmounted } from 'vue'
import { Document, Setting, Folder } from '@element-plus/icons-vue'
import { GetNovelList, GetCallWord, SetCallWord, GetModelConfig, SetModelConfig, OpenSourceDir, OpenGenerateDir } from '../../wailsjs/go/backend/App'
const props = defineProps({
activeButton: {
@@ -13,12 +13,99 @@ const props = defineProps({
const emit = defineEmits(['update:activeButton', 'menu-click'])
const novels = ref([])
const dialogVisible = ref(false)
const modelDialogVisible = ref(false)
const callWord = ref([])
const newCallWord = ref('')
const modelConfig = ref({})
const isLoading = ref(false)
function handleNovelClick(novelId, novelLabel) {
emit('update:activeButton', novelId)
emit('menu-click', { id: novelId, filename: novelLabel })
}
async function openCallWordDialog() {
isLoading.value = true
try {
const words = await GetCallWord()
callWord.value = words
dialogVisible.value = true
} catch (error) {
console.error('Error getting call word:', error)
} finally {
isLoading.value = false
}
}
async function openModelDialog() {
isLoading.value = true
try {
const config = await GetModelConfig()
modelConfig.value = config
modelDialogVisible.value = true
} catch (error) {
console.error('Error getting model config:', error)
} finally {
isLoading.value = false
}
}
async function saveModelConfig() {
isLoading.value = true
try {
await SetModelConfig(modelConfig.value)
modelDialogVisible.value = false
} catch (error) {
console.error('Error saving model config:', error)
} finally {
isLoading.value = false
}
}
// 打开原文目录
async function openSourceDirectory() {
try {
await OpenSourceDir()
} catch (error) {
console.error('Error opening source directory:', error)
}
}
// 打开仿文目录
async function openGenerateDirectory() {
try {
await OpenGenerateDir()
} catch (error) {
console.error('Error opening generate directory:', error)
}
}
async function saveCallWord() {
isLoading.value = true
try {
await SetCallWord(callWord.value)
dialogVisible.value = false
} catch (error) {
console.error('Error saving call word:', error)
} finally {
isLoading.value = false
}
}
function addCallWord() {
if (newCallWord.value.trim()) {
callWord.value.push(newCallWord.value.trim())
newCallWord.value = ''
}
}
function adjustTextareaHeight(event) {
const textarea = event.target
textarea.style.height = 'auto'
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'
}
async function loadNovelList() {
try {
const novelList = await GetNovelList()
@@ -35,6 +122,14 @@ async function loadNovelList() {
onMounted(() => {
// 组件挂载时加载小说列表
loadNovelList()
// 添加窗口焦点事件监听器
window.addEventListener('focus', loadNovelList)
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('focus', loadNovelList)
})
</script>
@@ -55,7 +150,163 @@ onMounted(() => {
<div v-if="novels.length === 0" class="empty-state">
暂无小说文件
</div>
<div class="setting-button">
<el-button
type="default"
size="small"
@click="openCallWordDialog"
:loading="isLoading"
>
<el-icon><Setting /></el-icon>
<span>设置提示词</span>
</el-button>
<el-button
type="default"
size="small"
@click="openModelDialog"
:loading="isLoading"
>
<el-icon><Setting /></el-icon>
<span>模型设置</span>
</el-button>
<div class="directory-buttons">
<el-button
type="default"
size="small"
@click="openSourceDirectory"
>
<el-icon><Folder /></el-icon>
<span>打开原文目录</span>
</el-button>
<el-button
type="default"
size="small"
@click="openGenerateDirectory"
>
<el-icon><Folder /></el-icon>
<span>打开仿文目录</span>
</el-button>
</div>
</div>
</div>
<!-- 提示词设置对话框 -->
<el-dialog
v-model="dialogVisible"
title="设置提示词"
width="800px"
:close-on-click-modal="false"
>
<div v-if="isLoading" class="dialog-loading">
<el-icon class="is-loading"><i-ep-loading /></el-icon>
加载中...
</div>
<div v-else>
<el-form>
<el-form-item>
<div
v-for="(word, index) in callWord"
:key="index"
class="call-word-item"
>
<el-input
v-model="callWord[index]"
type="textarea"
:rows="2"
class="call-word-textarea"
placeholder="输入提示词"
@input="adjustTextareaHeight"
/>
<el-button
type="danger"
size="small"
@click="callWord.splice(index, 1)"
class="delete-button"
>
删除
</el-button>
</div>
<div class="new-call-word-container">
<el-input
v-model="newCallWord"
type="textarea"
:rows="2"
placeholder="输入新的提示词"
class="new-call-word-input"
@keyup.enter="addCallWord"
@input="adjustTextareaHeight"
/>
<el-button
type="primary"
@click="addCallWord"
class="add-button"
>
添加
</el-button>
</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCallWord" :loading="isLoading">保存</el-button>
</span>
</template>
</el-dialog>
<!-- 模型设置对话框 -->
<el-dialog
v-model="modelDialogVisible"
title="模型设置"
width="600px"
:close-on-click-modal="false"
>
<div v-if="isLoading" class="dialog-loading">
<el-icon class="is-loading"><i-ep-loading /></el-icon>
加载中...
</div>
<div v-else>
<el-form>
<el-form-item label="API Key">
<el-input
v-model="modelConfig.key"
placeholder="输入API Key"
class="model-config-input"
/>
</el-form-item>
<el-form-item label="模型名称">
<el-input
v-model="modelConfig.model"
placeholder="输入模型名称"
class="model-config-input"
/>
</el-form-item>
<el-form-item label="输入价格">
<el-input
v-model.number="modelConfig.in"
type="number"
placeholder="输入价格"
class="model-config-input"
/>
</el-form-item>
<el-form-item label="输出价格">
<el-input
v-model.number="modelConfig.out"
type="number"
placeholder="输出价格"
class="model-config-input"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="modelDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveModelConfig" :loading="isLoading">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
@@ -111,4 +362,89 @@ onMounted(() => {
color: #95a5a6;
font-style: italic;
}
.setting-button {
margin-top: 20px;
padding: 0 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-button .el-button {
width: 100%;
justify-content: center;
height: 40px;
font-size: 14px;
margin: 0;
}
.directory-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}
.directory-buttons .el-button {
flex: 1;
justify-content: center;
height: 40px;
font-size: 14px;
margin: 0;
}
.call-word-item {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
gap: 10px;
width: 100%;
}
.call-word-textarea {
flex: 1;
min-height: 60px;
resize: none;
overflow: hidden;
}
.delete-button {
margin-top: 4px;
white-space: nowrap;
}
.new-call-word-container {
margin-top: 15px;
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
}
.new-call-word-input {
flex: 1;
min-height: 60px;
resize: none;
overflow: hidden;
}
.add-button {
margin-top: 4px;
white-space: nowrap;
}
.model-config-input {
width: 100%;
}
.dialog-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.dialog-loading .el-icon {
margin-right: 10px;
}
</style>

View File

@@ -1,8 +1,28 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {ai} from '../models';
import {backend} from '../models';
export function GetAIContent(arg1:string):Promise<Array<ai.Message>>;
export function GetCallWord():Promise<Array<string>>;
export function GetFangWenContent(arg1:string):Promise<string>;
export function GetModelConfig():Promise<backend.ModelConfig>;
export function GetNovelContent(arg1:string):Promise<string>;
export function GetNovelList():Promise<Array<string>>;
export function GetToken(arg1:string):Promise<string>;
export function OpenGenerateDir():Promise<void>;
export function OpenSourceDir():Promise<void>;
export function SetCallWord(arg1:Array<string>):Promise<void>;
export function SetModelConfig(arg1:backend.ModelConfig):Promise<void>;
export function WriteNovelContent(arg1:string):Promise<string>;

View File

@@ -2,6 +2,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetAIContent(arg1) {
return window['go']['backend']['App']['GetAIContent'](arg1);
}
export function GetCallWord() {
return window['go']['backend']['App']['GetCallWord']();
}
export function GetFangWenContent(arg1) {
return window['go']['backend']['App']['GetFangWenContent'](arg1);
}
export function GetModelConfig() {
return window['go']['backend']['App']['GetModelConfig']();
}
export function GetNovelContent(arg1) {
return window['go']['backend']['App']['GetNovelContent'](arg1);
}
@@ -10,6 +26,26 @@ export function GetNovelList() {
return window['go']['backend']['App']['GetNovelList']();
}
export function GetToken(arg1) {
return window['go']['backend']['App']['GetToken'](arg1);
}
export function OpenGenerateDir() {
return window['go']['backend']['App']['OpenGenerateDir']();
}
export function OpenSourceDir() {
return window['go']['backend']['App']['OpenSourceDir']();
}
export function SetCallWord(arg1) {
return window['go']['backend']['App']['SetCallWord'](arg1);
}
export function SetModelConfig(arg1) {
return window['go']['backend']['App']['SetModelConfig'](arg1);
}
export function WriteNovelContent(arg1) {
return window['go']['backend']['App']['WriteNovelContent'](arg1);
}

View File

@@ -0,0 +1,42 @@
export namespace ai {
export class Message {
role: string;
content: string;
static createFrom(source: any = {}) {
return new Message(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.role = source["role"];
this.content = source["content"];
}
}
}
export namespace backend {
export class ModelConfig {
key: string;
model: string;
in: number;
out: number;
static createFrom(source: any = {}) {
return new ModelConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.model = source["model"];
this.in = source["in"];
this.out = source["out"];
}
}
}

9
go.mod
View File

@@ -2,7 +2,11 @@ module fiction
go 1.23
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/wailsapp/wails/v2 v2.11.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/bep/debounce v1.2.1 // indirect
@@ -11,6 +15,8 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
@@ -19,6 +25,7 @@ require (
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect

10
go.sum
View File

@@ -12,6 +12,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@@ -34,6 +38,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -79,3 +85,7 @@ golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -25,6 +25,7 @@ func main() {
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.Startup,
OnShutdown: app.Shutdown,
Bind: []interface{}{
app,
},

View File

@@ -1 +0,0 @@
这个是旭旭宝宝