Almost done

This commit is contained in:
Roland 2026-05-31 19:31:27 +02:00
parent ae17d414e4
commit c790274f87
53 changed files with 5598 additions and 797 deletions

View file

@ -2,6 +2,42 @@
"Version": 1,
"WorkspaceRootPath": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\",
"Documents": [
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\install.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:install.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iollamasetupservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iollamasetupservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\settingsservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\settingsservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -18,21 +54,9 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg||{58961B49-13E0-48C0-9258-13CBC4D40279}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\themeservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\themeservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -45,14 +69,6 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\redirecttologin.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\redirecttologin.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\ollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\ollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\login.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\login.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
@ -61,22 +77,10 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\totphelper.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\totphelper.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\models\\ollamasettings.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:models\\ollamasettings.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\filescannerservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\filescannerservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -89,10 +93,6 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\ragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\ragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\navmenu.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\navmenu.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
@ -153,7 +153,7 @@
"DocumentGroups": [
{
"DockedWidth": 200,
"SelectedChildIndex": 3,
"SelectedChildIndex": 1,
"Children": [
{
"$type": "Bookmark",
@ -161,19 +161,36 @@
},
{
"$type": "Document",
"DocumentIndex": 4,
"Title": "UserEntity.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeDocumentMoniker": "Data\\UserEntity.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeToolTip": "Data\\UserEntity.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAIAAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-24T04:52:42.227Z"
"DocumentIndex": 0,
"Title": "INSTALL.md",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\INSTALL.md",
"RelativeDocumentMoniker": "INSTALL.md",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\INSTALL.md",
"RelativeToolTip": "INSTALL.md",
"ViewState": "AgIAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-05-31T17:07:33.423Z",
"EditorCaption": ""
},
{
"$type": "Bookmark",
"Name": "RST:0:0:{b3f9f954-0ca5-0091-76b4-34a47f44c954}"
},
{
"$type": "Document",
"DocumentIndex": 5,
"DocumentIndex": 4,
"Title": "IOllamaSetupService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaSetupService.cs",
"RelativeDocumentMoniker": "Services\\IOllamaSetupService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaSetupService.cs",
"RelativeToolTip": "Services\\IOllamaSetupService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-30T08:35:51.087Z"
},
{
"$type": "Document",
"DocumentIndex": 13,
"Title": "A7405018.jpg",
"DocumentMoniker": "C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg",
"ToolTip": "C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg",
@ -182,20 +199,104 @@
},
{
"$type": "Document",
"DocumentIndex": 0,
"DocumentIndex": 9,
"Title": "SettingsService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\SettingsService.cs",
"RelativeDocumentMoniker": "Services\\SettingsService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\SettingsService.cs",
"RelativeToolTip": "Services\\SettingsService.cs",
"ViewState": "AgIAAAMBAAAAAAAAAAAAABsBAAApAAAAAAAAAA==",
"ViewState": "AgIAAE0BAAAAAAAAAAAAAC0BAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-21T17:37:24.777Z",
"EditorCaption": ""
"WhenOpened": "2026-05-21T17:37:24.777Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "Settings.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Settings.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeToolTip": "Components\\Pages\\Settings.razor",
"ViewState": "AgIAAJsAAAAAAAAAAAAAAKwAAAAWAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:43:26.6Z"
},
{
"$type": "Document",
"DocumentIndex": 1,
"Title": "Editor.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Editor.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeToolTip": "Components\\Pages\\Editor.razor",
"ViewState": "AgIAAFsCAAAAAAAAAAAiwEgDAAAkAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-16T13:01:59.743Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 2,
"Title": "ProjectService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeDocumentMoniker": "Services\\ProjectService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeToolTip": "Services\\ProjectService.cs",
"ViewState": "AgIAAN0AAAAAAAAAAAApwO0AAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-15T15:56:55.229Z"
},
{
"$type": "Document",
"DocumentIndex": 3,
"Title": "OllamaAssistant.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeDocumentMoniker": "Components\\Pages\\OllamaAssistant.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeToolTip": "Components\\Pages\\OllamaAssistant.razor",
"ViewState": "AgIAANoAAAAAAAAAAAAiwPIAAAAZAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-20T05:14:39.037Z"
},
{
"$type": "Document",
"DocumentIndex": 5,
"Title": "MainLayout.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeDocumentMoniker": "Components\\Layout\\MainLayout.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeToolTip": "Components\\Layout\\MainLayout.razor",
"ViewState": "AgIAACABAAAAAAAAAAApwDQBAAARAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:36.444Z"
},
{
"$type": "Document",
"DocumentIndex": 8,
"Title": "UserEntity.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeDocumentMoniker": "Data\\UserEntity.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeToolTip": "Data\\UserEntity.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAsAAAAVAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-24T04:52:42.227Z"
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "Program.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeDocumentMoniker": "Program.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeToolTip": "Program.cs",
"ViewState": "AgIAACwAAAAAAAAAAAAQwD8AAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-20T04:07:21.54Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"Title": "IThemeService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IThemeService.cs",
"RelativeDocumentMoniker": "Services\\IThemeService.cs",
@ -203,12 +304,11 @@
"RelativeToolTip": "Services\\IThemeService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAgAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-21T17:32:01.784Z",
"EditorCaption": ""
"WhenOpened": "2026-05-21T17:32:01.784Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"DocumentIndex": 16,
"Title": "RedirectToLogin.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\RedirectToLogin.razor",
"RelativeDocumentMoniker": "Components\\RedirectToLogin.razor",
@ -220,31 +320,7 @@
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "OllamaAssistant.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeDocumentMoniker": "Components\\Pages\\OllamaAssistant.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeToolTip": "Components\\Pages\\OllamaAssistant.razor",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAGgAAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-20T05:14:39.037Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "MainLayout.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeDocumentMoniker": "Components\\Layout\\MainLayout.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeToolTip": "Components\\Layout\\MainLayout.razor",
"ViewState": "AgIAAB4BAAAAAAAAAAApwDQBAAApAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:36.444Z"
},
{
"$type": "Document",
"DocumentIndex": 2,
"DocumentIndex": 11,
"Title": "ISettingsService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ISettingsService.cs",
"RelativeDocumentMoniker": "Services\\ISettingsService.cs",
@ -256,7 +332,7 @@
},
{
"$type": "Document",
"DocumentIndex": 3,
"DocumentIndex": 12,
"Title": "IRagService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IRagService.cs",
"RelativeDocumentMoniker": "Services\\IRagService.cs",
@ -268,7 +344,7 @@
},
{
"$type": "Document",
"DocumentIndex": 13,
"DocumentIndex": 17,
"Title": "Login.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Login.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Login.razor",
@ -280,7 +356,7 @@
},
{
"$type": "Document",
"DocumentIndex": 8,
"DocumentIndex": 14,
"Title": "ThemeService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ThemeService.cs",
"RelativeDocumentMoniker": "Services\\ThemeService.cs",
@ -292,31 +368,7 @@
},
{
"$type": "Document",
"DocumentIndex": 11,
"Title": "Program.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeDocumentMoniker": "Program.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeToolTip": "Program.cs",
"ViewState": "AgIAABwAAAAAAAAAAAAAABIAAAAsAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-20T04:07:21.54Z"
},
{
"$type": "Document",
"DocumentIndex": 12,
"Title": "OllamaService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\OllamaService.cs",
"RelativeDocumentMoniker": "Services\\OllamaService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\OllamaService.cs",
"RelativeToolTip": "Services\\OllamaService.cs",
"ViewState": "AgIAAGEAAAAAAAAAAAAcwHsAAAAVAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-16T06:43:46.404Z"
},
{
"$type": "Document",
"DocumentIndex": 9,
"DocumentIndex": 15,
"Title": "logo.jpg",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Assets\\logo.jpg",
"RelativeDocumentMoniker": "Assets\\logo.jpg",
@ -327,7 +379,7 @@
},
{
"$type": "Document",
"DocumentIndex": 16,
"DocumentIndex": 19,
"Title": "OllamaSettings.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Models\\OllamaSettings.cs",
"RelativeDocumentMoniker": "Models\\OllamaSettings.cs",
@ -350,7 +402,7 @@
},
{
"$type": "Document",
"DocumentIndex": 19,
"DocumentIndex": 20,
"Title": "FileScannerService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\FileScannerService.cs",
"RelativeDocumentMoniker": "Services\\FileScannerService.cs",
@ -362,19 +414,7 @@
},
{
"$type": "Document",
"DocumentIndex": 17,
"Title": "IOllamaService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaService.cs",
"RelativeDocumentMoniker": "Services\\IOllamaService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaService.cs",
"RelativeToolTip": "Services\\IOllamaService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAkAAAAPAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-16T07:20:19.212Z"
},
{
"$type": "Document",
"DocumentIndex": 14,
"DocumentIndex": 18,
"Title": "TotpHelper.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\TotpHelper.cs",
"RelativeDocumentMoniker": "Services\\TotpHelper.cs",
@ -386,7 +426,7 @@
},
{
"$type": "Document",
"DocumentIndex": 20,
"DocumentIndex": 21,
"Title": "Project.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Models\\Project.cs",
"RelativeDocumentMoniker": "Models\\Project.cs",
@ -398,19 +438,7 @@
},
{
"$type": "Document",
"DocumentIndex": 15,
"Title": "Settings.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Settings.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeToolTip": "Components\\Pages\\Settings.razor",
"ViewState": "AgIAAAwAAAAAAAAAAAAAAIYAAAAlAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:43:26.6Z"
},
{
"$type": "Document",
"DocumentIndex": 21,
"DocumentIndex": 22,
"Title": "RagService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\RagService.cs",
"RelativeDocumentMoniker": "Services\\RagService.cs",
@ -420,18 +448,6 @@
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-18T15:40:05.413Z"
},
{
"$type": "Document",
"DocumentIndex": 18,
"Title": "ProjectService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeDocumentMoniker": "Services\\ProjectService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeToolTip": "Services\\ProjectService.cs",
"ViewState": "AgIAADQAAAAAAAAAAAAqwEYAAAAWAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-15T15:56:55.229Z"
},
{
"$type": "Document",
"DocumentIndex": 23,
@ -444,18 +460,6 @@
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:37.739Z"
},
{
"$type": "Document",
"DocumentIndex": 22,
"Title": "Editor.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Editor.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeToolTip": "Components\\Pages\\Editor.razor",
"ViewState": "AgIAAAoAAACA6NuCpTYvwKAAAAAcAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-16T13:01:59.743Z"
},
{
"$type": "Document",
"DocumentIndex": 25,
@ -587,10 +591,6 @@
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
"WhenOpened": "2026-05-14T10:14:25.605Z"
},
{
"$type": "Bookmark",
"Name": "RST:0:0:{b3f9f954-0ca5-0091-76b4-34a47f44c954}"
}
]
}

View file

@ -2,6 +2,42 @@
"Version": 1,
"WorkspaceRootPath": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\",
"Documents": [
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\install.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:install.md||{EFC0BB08-EA7D-40C6-A696-C870411A895B}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iollamasetupservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iollamasetupservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\settingsservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\settingsservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -18,21 +54,9 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:data\\userentity.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg||{58961B49-13E0-48C0-9258-13CBC4D40279}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\ollamaassistant.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\mainlayout.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\themeservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\themeservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -45,14 +69,6 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\redirecttologin.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\redirecttologin.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\ollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\ollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\login.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\login.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
@ -61,22 +77,10 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\totphelper.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\totphelper.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\settings.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\models\\ollamasettings.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:models\\ollamasettings.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\iollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\iollamaservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\projectservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\filescannerservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\filescannerservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
@ -89,10 +93,6 @@
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\services\\ragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:services\\ragservice.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\pages\\editor.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
},
{
"AbsoluteMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|e:\\ollama_net\\ollama\\authorbuddy.web\\components\\layout\\navmenu.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}",
"RelativeMoniker": "D:0:0:{4F4697EF-EFFA-26AC-C8A1-C9859805D247}|AuthorBuddy.Web.csproj|solutionrelative:components\\layout\\navmenu.razor||{40D31677-CBC0-4297-A9EF-89D907823A98}"
@ -153,27 +153,44 @@
"DocumentGroups": [
{
"DockedWidth": 200,
"SelectedChildIndex": 3,
"SelectedChildIndex": 2,
"Children": [
{
"$type": "Bookmark",
"Name": "ST:0:0:{aa2115a1-9712-457b-9047-dbb71ca2cdd2}"
},
{
"$type": "Document",
"DocumentIndex": 4,
"Title": "UserEntity.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeDocumentMoniker": "Data\\UserEntity.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeToolTip": "Data\\UserEntity.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAIAAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-24T04:52:42.227Z"
"$type": "Bookmark",
"Name": "RST:0:0:{b3f9f954-0ca5-0091-76b4-34a47f44c954}"
},
{
"$type": "Document",
"DocumentIndex": 5,
"DocumentIndex": 0,
"Title": "INSTALL.md",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\INSTALL.md",
"RelativeDocumentMoniker": "INSTALL.md",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\INSTALL.md",
"RelativeToolTip": "INSTALL.md",
"ViewState": "AgIAAFQAAAAAAAAAAAAAAGwAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001818|",
"WhenOpened": "2026-05-31T17:07:33.423Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 4,
"Title": "IOllamaSetupService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaSetupService.cs",
"RelativeDocumentMoniker": "Services\\IOllamaSetupService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaSetupService.cs",
"RelativeToolTip": "Services\\IOllamaSetupService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-30T08:35:51.087Z"
},
{
"$type": "Document",
"DocumentIndex": 13,
"Title": "A7405018.jpg",
"DocumentMoniker": "C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg",
"ToolTip": "C:\\Users\\HP\\Desktop\\Website\\A7405018.jpg",
@ -182,20 +199,104 @@
},
{
"$type": "Document",
"DocumentIndex": 0,
"DocumentIndex": 9,
"Title": "SettingsService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\SettingsService.cs",
"RelativeDocumentMoniker": "Services\\SettingsService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\SettingsService.cs",
"RelativeToolTip": "Services\\SettingsService.cs",
"ViewState": "AgIAAO4AAAAAAAAAAAAqwBsBAAApAAAAAAAAAA==",
"ViewState": "AgIAAE0BAAAAAAAAAAAAAC0BAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-21T17:37:24.777Z",
"EditorCaption": ""
"WhenOpened": "2026-05-21T17:37:24.777Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "Settings.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Settings.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeToolTip": "Components\\Pages\\Settings.razor",
"ViewState": "AgIAAJsAAAAAAAAAAAAAAKwAAAAWAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:43:26.6Z"
},
{
"$type": "Document",
"DocumentIndex": 1,
"Title": "Editor.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Editor.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeToolTip": "Components\\Pages\\Editor.razor",
"ViewState": "AgIAAFsCAAAAAAAAAAAiwEgDAAAkAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-16T13:01:59.743Z",
"EditorCaption": ""
},
{
"$type": "Document",
"DocumentIndex": 2,
"Title": "ProjectService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeDocumentMoniker": "Services\\ProjectService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeToolTip": "Services\\ProjectService.cs",
"ViewState": "AgIAAN0AAAAAAAAAAAApwO0AAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-15T15:56:55.229Z"
},
{
"$type": "Document",
"DocumentIndex": 3,
"Title": "OllamaAssistant.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeDocumentMoniker": "Components\\Pages\\OllamaAssistant.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeToolTip": "Components\\Pages\\OllamaAssistant.razor",
"ViewState": "AgIAANoAAAAAAAAAAAAiwPIAAAAZAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-20T05:14:39.037Z"
},
{
"$type": "Document",
"DocumentIndex": 5,
"Title": "MainLayout.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeDocumentMoniker": "Components\\Layout\\MainLayout.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeToolTip": "Components\\Layout\\MainLayout.razor",
"ViewState": "AgIAACABAAAAAAAAAAApwDQBAAARAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:36.444Z"
},
{
"$type": "Document",
"DocumentIndex": 8,
"Title": "UserEntity.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeDocumentMoniker": "Data\\UserEntity.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Data\\UserEntity.cs",
"RelativeToolTip": "Data\\UserEntity.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAsAAAAVAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-24T04:52:42.227Z"
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "Program.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeDocumentMoniker": "Program.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeToolTip": "Program.cs",
"ViewState": "AgIAACwAAAAAAAAAAAAQwD8AAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-20T04:07:21.54Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"Title": "IThemeService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IThemeService.cs",
"RelativeDocumentMoniker": "Services\\IThemeService.cs",
@ -203,12 +304,11 @@
"RelativeToolTip": "Services\\IThemeService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAgAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-21T17:32:01.784Z",
"EditorCaption": ""
"WhenOpened": "2026-05-21T17:32:01.784Z"
},
{
"$type": "Document",
"DocumentIndex": 10,
"DocumentIndex": 16,
"Title": "RedirectToLogin.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\RedirectToLogin.razor",
"RelativeDocumentMoniker": "Components\\RedirectToLogin.razor",
@ -220,31 +320,7 @@
},
{
"$type": "Document",
"DocumentIndex": 6,
"Title": "OllamaAssistant.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeDocumentMoniker": "Components\\Pages\\OllamaAssistant.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\OllamaAssistant.razor",
"RelativeToolTip": "Components\\Pages\\OllamaAssistant.razor",
"ViewState": "AgIAAAAAAAAAAAAAAAAAAGgAAAAMAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-20T05:14:39.037Z"
},
{
"$type": "Document",
"DocumentIndex": 7,
"Title": "MainLayout.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeDocumentMoniker": "Components\\Layout\\MainLayout.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Layout\\MainLayout.razor",
"RelativeToolTip": "Components\\Layout\\MainLayout.razor",
"ViewState": "AgIAAB4BAAAAAAAAAAApwDQBAAApAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:36.444Z"
},
{
"$type": "Document",
"DocumentIndex": 2,
"DocumentIndex": 11,
"Title": "ISettingsService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ISettingsService.cs",
"RelativeDocumentMoniker": "Services\\ISettingsService.cs",
@ -256,7 +332,7 @@
},
{
"$type": "Document",
"DocumentIndex": 3,
"DocumentIndex": 12,
"Title": "IRagService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IRagService.cs",
"RelativeDocumentMoniker": "Services\\IRagService.cs",
@ -268,7 +344,7 @@
},
{
"$type": "Document",
"DocumentIndex": 13,
"DocumentIndex": 17,
"Title": "Login.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Login.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Login.razor",
@ -280,7 +356,7 @@
},
{
"$type": "Document",
"DocumentIndex": 8,
"DocumentIndex": 14,
"Title": "ThemeService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ThemeService.cs",
"RelativeDocumentMoniker": "Services\\ThemeService.cs",
@ -292,31 +368,7 @@
},
{
"$type": "Document",
"DocumentIndex": 11,
"Title": "Program.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeDocumentMoniker": "Program.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Program.cs",
"RelativeToolTip": "Program.cs",
"ViewState": "AgIAABwAAAAAAAAAAAAAABIAAAAsAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-20T04:07:21.54Z"
},
{
"$type": "Document",
"DocumentIndex": 12,
"Title": "OllamaService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\OllamaService.cs",
"RelativeDocumentMoniker": "Services\\OllamaService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\OllamaService.cs",
"RelativeToolTip": "Services\\OllamaService.cs",
"ViewState": "AgIAAGEAAAAAAAAAAAAcwHsAAAAVAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-16T06:43:46.404Z"
},
{
"$type": "Document",
"DocumentIndex": 9,
"DocumentIndex": 15,
"Title": "logo.jpg",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Assets\\logo.jpg",
"RelativeDocumentMoniker": "Assets\\logo.jpg",
@ -327,7 +379,7 @@
},
{
"$type": "Document",
"DocumentIndex": 16,
"DocumentIndex": 19,
"Title": "OllamaSettings.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Models\\OllamaSettings.cs",
"RelativeDocumentMoniker": "Models\\OllamaSettings.cs",
@ -350,7 +402,7 @@
},
{
"$type": "Document",
"DocumentIndex": 19,
"DocumentIndex": 20,
"Title": "FileScannerService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\FileScannerService.cs",
"RelativeDocumentMoniker": "Services\\FileScannerService.cs",
@ -362,19 +414,7 @@
},
{
"$type": "Document",
"DocumentIndex": 17,
"Title": "IOllamaService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaService.cs",
"RelativeDocumentMoniker": "Services\\IOllamaService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\IOllamaService.cs",
"RelativeToolTip": "Services\\IOllamaService.cs",
"ViewState": "AgIAAAAAAAAAAAAAAAAqwAkAAAAPAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-16T07:20:19.212Z"
},
{
"$type": "Document",
"DocumentIndex": 14,
"DocumentIndex": 18,
"Title": "TotpHelper.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\TotpHelper.cs",
"RelativeDocumentMoniker": "Services\\TotpHelper.cs",
@ -386,7 +426,7 @@
},
{
"$type": "Document",
"DocumentIndex": 20,
"DocumentIndex": 21,
"Title": "Project.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Models\\Project.cs",
"RelativeDocumentMoniker": "Models\\Project.cs",
@ -398,19 +438,7 @@
},
{
"$type": "Document",
"DocumentIndex": 15,
"Title": "Settings.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Settings.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Settings.razor",
"RelativeToolTip": "Components\\Pages\\Settings.razor",
"ViewState": "AgIAAAwAAAAAAAAAAAAAAIYAAAAlAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:43:26.6Z"
},
{
"$type": "Document",
"DocumentIndex": 21,
"DocumentIndex": 22,
"Title": "RagService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\RagService.cs",
"RelativeDocumentMoniker": "Services\\RagService.cs",
@ -420,18 +448,6 @@
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-18T15:40:05.413Z"
},
{
"$type": "Document",
"DocumentIndex": 18,
"Title": "ProjectService.cs",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeDocumentMoniker": "Services\\ProjectService.cs",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Services\\ProjectService.cs",
"RelativeToolTip": "Services\\ProjectService.cs",
"ViewState": "AgIAADQAAAAAAAAAAAAqwEYAAAAWAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
"WhenOpened": "2026-05-15T15:56:55.229Z"
},
{
"$type": "Document",
"DocumentIndex": 23,
@ -444,18 +460,6 @@
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-15T16:04:37.739Z"
},
{
"$type": "Document",
"DocumentIndex": 22,
"Title": "Editor.razor",
"DocumentMoniker": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeDocumentMoniker": "Components\\Pages\\Editor.razor",
"ToolTip": "E:\\ollama_net\\ollama\\AuthorBuddy.Web\\Components\\Pages\\Editor.razor",
"RelativeToolTip": "Components\\Pages\\Editor.razor",
"ViewState": "AgIAAAoAAACA6NuCpTYvwKAAAAAcAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000759|",
"WhenOpened": "2026-05-16T13:01:59.743Z"
},
{
"$type": "Document",
"DocumentIndex": 25,
@ -587,10 +591,6 @@
"ViewState": "AgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
"Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.001642|",
"WhenOpened": "2026-05-14T10:14:25.605Z"
},
{
"$type": "Bookmark",
"Name": "RST:0:0:{b3f9f954-0ca5-0091-76b4-34a47f44c954}"
}
]
}

Binary file not shown.

View file

@ -9,7 +9,9 @@
<ItemGroup>
<PackageReference Include="DiffPlex" Version="1.9.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
<PackageReference Include="LiteDB" Version="5.0.21" />
<PackageReference Include="QuestPDF" Version="2026.5.0" />
</ItemGroup>
<ItemGroup>

View file

@ -20,6 +20,8 @@
</CascadingAuthenticationState>
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["js/editor-toolbar.js"]"></script>
<script src="@Assets["js/download.js"]"></script>
</body>
</html>

View file

@ -0,0 +1,118 @@
@using AuthorBuddy.Web.Models
@inject IProjectService ProjectService
<div class="category-selector" @onclick:stopPropagation>
@foreach (var cat in _allCategories)
{
var isActive = (Current & cat) == cat;
var disabled = _disabledCategories.Contains(cat);
<button class="category-btn @(isActive ? "active" : "") @(disabled ? "disabled" : "")"
@onclick="() => ToggleCategory(cat)"
disabled="@disabled"
title="@CategoryName(cat)">
@CategoryIcon(cat)
<span class="cat-label">@CategoryName(cat)</span>
</button>
}
</div>
<style>
.category-selector {
display: inline-flex;
gap: 3px;
flex-wrap: wrap;
}
.category-btn {
font-size: 11px;
padding: 1px 6px;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
background: var(--bg-card, #f5f5f5);
color: var(--text-main, #333);
cursor: pointer;
line-height: 1.5;
display: inline-flex;
align-items: center;
gap: 2px;
transition: all 0.15s;
}
.category-btn.active {
background: var(--accent-color, #4a90d9);
color: #fff;
border-color: var(--accent-color, #4a90d9);
}
.category-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.category-btn:not(.disabled):hover {
opacity: 0.85;
}
.cat-label {
font-size: 10px;
}
</style>
@code {
private static readonly FileCategory[] _allCategories = new[]
{
FileCategory.Character,
FileCategory.Exposee,
FileCategory.Fact,
FileCategory.Document
};
private HashSet<FileCategory> _disabledCategories = new();
[Parameter] public FileCategory Current { get; set; }
[Parameter] public int ProjectId { get; set; }
[Parameter] public string FilePath { get; set; } = string.Empty;
[Parameter] public EventCallback<FileCategory> OnCategoryChanged { get; set; }
protected override void OnParametersSet()
{
UpdateDisabled();
}
private void UpdateDisabled()
{
_disabledCategories.Clear();
foreach (var cat in _allCategories)
{
if (cat == Current) continue;
var combined = Current | cat;
if (!FileCategoryHelper.IsValid(combined))
_disabledCategories.Add(cat);
}
}
private async Task ToggleCategory(FileCategory cat)
{
if (_disabledCategories.Contains(cat)) return;
var newCategory = Current.HasFlag(cat) ? Current & ~cat : Current | cat;
if (!FileCategoryHelper.IsValid(newCategory)) return;
await ProjectService.SetFileCategoryAsync(ProjectId, FilePath, newCategory);
await OnCategoryChanged.InvokeAsync(newCategory);
}
private static string CategoryName(FileCategory cat) => cat switch
{
FileCategory.Character => "Charakter",
FileCategory.Exposee => "Exposé",
FileCategory.Fact => "Fakt",
FileCategory.Document => "Dokument",
_ => ""
};
private static string CategoryIcon(FileCategory cat) => cat switch
{
FileCategory.Character => "👤",
FileCategory.Exposee => "📋",
FileCategory.Fact => "📌",
FileCategory.Document => "📄",
_ => ""
};
}

View file

@ -0,0 +1,149 @@
@inject IJSRuntime JS
@inject ISettingsService SettingsService
@inject IProjectService ProjectService
<div class="copy-create-toolbar">
<button class="btn btn-sm" @onclick="CopyToClipboard" title="In Zwischenablage kopieren">📋 Kopieren</button>
<button class="btn btn-sm" @onclick="ShowCreateDialog" title="Als neue .md Datei speichern">📄 Als MD speichern</button>
</div>
@if (showCreateDialog)
{
<div class="modal-overlay">
<div class="modal-dialog">
<div class="modal-title">Neue .md Datei erstellen</div>
<div class="modal-body">
@if (!string.IsNullOrEmpty(_projectPath))
{
<div class="form-group">
<label>Zielverzeichnis:</label>
<span style="font-size:11px;color:var(--border-color);word-break:break-all;">@_projectPath</span>
</div>
}
<div class="form-group">
<label>Dateiname:</label>
<input @bind="newFileName" placeholder="Dateiname (ohne .md)" />
</div>
</div>
<div class="modal-actions">
<button @onclick="CancelCreate">Abbrechen</button>
<button @onclick="CreateFile" class="primary" disabled="@(string.IsNullOrWhiteSpace(newFileName))">Erstellen</button>
</div>
</div>
</div>
}
<style>
.copy-create-toolbar {
display: inline-flex;
gap: 4px;
margin-left: 8px;
vertical-align: middle;
}
.copy-create-toolbar .btn-sm {
font-size: 11px;
padding: 2px 8px;
line-height: 1.4;
}
</style>
@code {
[Parameter] public string Text { get; set; } = string.Empty;
[Parameter] public string? DefaultFileName { get; set; }
[Parameter] public EventCallback<string> OnFileCreated { get; set; }
private bool showCreateDialog;
private string newFileName = string.Empty;
private int? _projectId;
private string? _projectPath;
private bool _isCopyDisabled;
protected override async Task OnInitializedAsync()
{
_projectId = SettingsService.CurrentProjectId;
if (_projectId.HasValue && _projectId > 0)
{
var project = await ProjectService.GetProjectAsync(_projectId.Value);
_projectPath = project?.RootPath;
}
if (!string.IsNullOrEmpty(DefaultFileName))
newFileName = DefaultFileName;
}
private async Task CopyToClipboard()
{
if (string.IsNullOrEmpty(Text) || _isCopyDisabled) return;
_isCopyDisabled = true;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", Text);
}
catch
{
// Fallback for older browsers
try
{
var textArea = System.Text.Encodings.Web.JavaScriptEncoder.Default.Encode(Text);
await JS.InvokeVoidAsync("eval", $"navigator.clipboard.writeText('{textArea}')");
}
catch { }
}
finally
{
_isCopyDisabled = false;
}
}
private void ShowCreateDialog()
{
newFileName = DefaultFileName ?? string.Empty;
showCreateDialog = true;
}
private void CancelCreate()
{
showCreateDialog = false;
newFileName = string.Empty;
}
private async Task CreateFile()
{
if (string.IsNullOrWhiteSpace(newFileName) || string.IsNullOrEmpty(Text)) return;
try
{
var fileName = newFileName.Trim();
if (!fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
fileName += ".md";
string savePath;
if (!string.IsNullOrEmpty(_projectPath) && Directory.Exists(_projectPath))
{
savePath = Path.Combine(_projectPath, fileName);
}
else
{
savePath = Path.Combine(Directory.GetCurrentDirectory(), fileName);
}
if (File.Exists(savePath))
{
var dir = Path.GetDirectoryName(savePath)!;
var name = Path.GetFileNameWithoutExtension(savePath);
var ext = Path.GetExtension(savePath);
var counter = 1;
do
{
savePath = Path.Combine(dir, $"{name}_{counter}{ext}");
counter++;
} while (File.Exists(savePath));
}
await File.WriteAllTextAsync(savePath, Text);
showCreateDialog = false;
await OnFileCreated.InvokeAsync(Path.GetFileName(savePath));
}
catch { }
}
}

View file

@ -3,10 +3,13 @@
@inject IProjectService ProjectService
@inject ISettingsService SettingsService
@inject IThemeService ThemeService
@inject IOllamaSetupService SetupService
@inject OllamaSettings OllamaSettings
@inject IAuthService AuthService
@inject CustomAuthStateProvider AuthStateProvider
@inject ILocalizationService Loc
@inject OperationState OpState
@using AuthorBuddy.Web.Models
@if (_isLoading)
{
@ -54,12 +57,18 @@ else
{
<div class="nav-section-body">
<button class="nav-item" @onclick='() => NavigateTo("/settings")'>@Loc["nav.menu.extras.settings"]</button>
<button class="nav-item" @onclick='() => NavigateTo("/models")'>@Loc["nav.menu.extras.manage_models"]</button>
<button class="nav-item" @onclick='() => NavigateTo("/assistant")'>@Loc["nav.menu.extras.assistant"]</button>
@if (_currentUserRole == UserRole.Administrator)
{
<div class="nav-separator"></div>
<button class="nav-item" @onclick='() => NavigateTo("/models")'>@Loc["nav.menu.extras.manage_models"]</button>
<button class="nav-item" @onclick='() => NavigateTo("/assistant")'>@Loc["nav.menu.extras.assistant"]</button>
<button class="nav-item" @onclick='() => NavigateTo("/setup-assistant")'>Setup Assistent</button>
<div class="nav-separator"></div>
<button class="nav-item" @onclick='() => NavigateTo("/user-management")'>@Loc["nav.menu.extras.user_management"]</button>
<button class="nav-item" @onclick='() => NavigateTo("/db-admin")'>📊 DB-Admin</button>
}
else
{
<button class="nav-item" @onclick='() => NavigateTo("/assistant")'>@Loc["nav.menu.extras.assistant"]</button>
}
</div>
}
@ -111,38 +120,110 @@ else
<span>@Loc["nav.projects.files"]</span>
<span class="nav-header-actions">
<button class="nav-item-icon" @onclick="RefreshFileTree" title="@Loc["nav.projects.refresh_tree"]">↻</button>
@if (_selectedFiles.Count > 0)
{
<button class="nav-item-icon" @onclick="() => _showExportDialog = true" title="Export (@(_selectedFiles.Count))">⬇</button>
}
</span>
</div>
<div class="nav-section-body">
@foreach (var item in fileTree)
{
var cat = item.Category;
<div class="tree-item @(item.IsDirectory ? "dir" : "file") @(item.FullPath == currentFilePath ? "active" : "")"
@onclick="() => OpenFile(item)">
@if (!item.IsDirectory)
{
<input type="checkbox" class="tree-checkbox"
checked="@_selectedFiles.Contains(item.FullPath)"
@onclick:stopPropagation
@onchange="() => ToggleFileSelection(item.FullPath)" />
}
<span class="tree-icon">@(item.IsDirectory ? "📁" : "📄")</span>
<span class="tree-name">@item.Name</span>
@if (cat != FileCategory.None)
{
<span class="tree-category-badge @FileCategoryHelper.CssClass(cat)" title="@FileCategoryHelper.DisplayName(cat)">@FileCategoryHelper.DisplayIcon(cat)</span>
}
@if (item.IsDirectory)
{
<button class="tree-rename" @onclick="() => BeginNewFile(item)" @onclick:stopPropagation title="@Loc["nav.projects.file.new"]">+</button>
}
else
{
<button class="tree-cat-btn" @onclick="() => ToggleCategoryEditor(item)" @onclick:stopPropagation title="Kategorie">🏷</button>
<button class="tree-rename" @onclick="() => BeginRename(item)" @onclick:stopPropagation title="@Loc["nav.projects.file.rename"]">✎</button>
}
</div>
@if (_editingCategoryPath == item.FullPath)
{
<div class="tree-cat-editor" @onclick:stopPropagation>
<CategorySelector Current="@item.Category" ProjectId="@currentProjectId" FilePath="@item.FullPath" OnCategoryChanged="@(v => OnCategoryChanged(item, v))" />
<button class="tree-cat-close" @onclick="() => _editingCategoryPath = null">✕</button>
</div>
}
@foreach (var child in item.Children)
{
var ccat = child.Category;
<div class="tree-item file tree-child @(child.FullPath == currentFilePath ? "active" : "")"
@onclick="() => OpenFile(child)">
<input type="checkbox" class="tree-checkbox"
checked="@_selectedFiles.Contains(child.FullPath)"
@onclick:stopPropagation
@onchange="() => ToggleFileSelection(child.FullPath)" />
<span class="tree-icon">📄</span>
<span class="tree-name">@child.Name</span>
@if (ccat != FileCategory.None)
{
<span class="tree-category-badge @FileCategoryHelper.CssClass(ccat)" title="@FileCategoryHelper.DisplayName(ccat)">@FileCategoryHelper.DisplayIcon(ccat)</span>
}
<button class="tree-cat-btn" @onclick="() => ToggleCategoryEditor(child)" @onclick:stopPropagation title="Kategorie">🏷</button>
<button class="tree-rename" @onclick="() => BeginRename(child)" @onclick:stopPropagation title="@Loc["nav.projects.file.rename"]">✎</button>
</div>
@if (_editingCategoryPath == child.FullPath)
{
<div class="tree-cat-editor" @onclick:stopPropagation>
<CategorySelector Current="@child.Category" ProjectId="@currentProjectId" FilePath="@child.FullPath" OnCategoryChanged="@(v => OnCategoryChanged(child, v))" />
<button class="tree-cat-close" @onclick="() => _editingCategoryPath = null">✕</button>
</div>
}
}
}
</div>
</div>
}
@if (_showExportDialog)
{
<div class="modal-overlay">
<div class="modal-dialog" style="min-width:360px;">
<div class="modal-title">@Loc["export.title"]</div>
<div class="modal-body">
<p style="margin-bottom:12px;">@Loc["export.file_count"]: <strong>@_selectedFiles.Count</strong></p>
<div class="form-group" style="margin-bottom:12px;">
<label>@Loc["export.label.filename"]</label>
<input @bind="exportFileName" placeholder="@Loc["export.placeholder.filename"]" />
<small class="form-hint">@Loc["export.hint.filename"]</small>
</div>
<div class="form-group">
<label>@Loc["export.label.format"]</label>
<select class="form-select" @bind="exportFormat">
<option value="Html">HTML (.html)</option>
<option value="Markdown">@Loc["export.format.markdown"] (.md)</option>
<option value="Zip">@Loc["export.format.zip"] (.zip)</option>
<option value="Docx">DOCX (.docx)</option>
<option value="Pdf">PDF (.pdf)</option>
</select>
</div>
</div>
<div class="modal-actions">
<button @onclick='() => _showExportDialog = false'>@Loc["common.cancel"]</button>
<button @onclick="DoExport" class="primary">@Loc["export.button.export"]</button>
</div>
</div>
</div>
}
@if (showRenameDialog)
{
<div class="modal-overlay">
@ -175,6 +256,7 @@ else
<div class="form-group">
<label>@Loc["nav.copy_project.label.path"]</label>
<input @bind="copyProjectPath" placeholder="@Loc["nav.copy_project.placeholder.path"]" />
<small class="form-hint">Erlaubter Pfad: @ProjectPathHelper.AllowedBasePath</small>
</div>
</div>
<div class="modal-actions">
@ -218,27 +300,80 @@ else
}
<div class="sidebar-spacer"></div>
<button class="sidebar-info-btn" @onclick="ToggleInfoOverlay" title="Info">i</button>
<div class="sidebar-bottom-btns">
<button class="sidebar-info-btn" @onclick="ToggleInfoOverlay" title="Info">i</button>
<button class="sidebar-info-btn" @onclick='() => Navigation.NavigateTo("/help")' title="@Loc["nav.menu.extras.help"]">?</button>
</div>
</aside>
}
<main class="detail-panel">
@if (currentProjectId > 0)
{
<div class="main-header">
<h2>@currentProjectName</h2>
</div>
}
@Body
</main>
</div>
<footer class="status-bar">
<span class="status-dot connected"></span>
<span class="status-text">@statusText</span>
@if (isBusy)
<span class="status-text">@OpState.StatusText</span>
@if (OpState.IsBusy)
{
<progress value="@operationProgress" max="100"></progress>
<progress value="@OpState.Progress" max="100"></progress>
}
@if (!string.IsNullOrEmpty(OpState.ThoughtText))
{
<span class="status-thought">@OpState.ThoughtText</span>
}
</footer>
</div>
}
<style>
.tree-category-badge {
font-size: 11px;
margin-left: 4px;
display: inline-flex;
align-items: center;
}
.tree-cat-btn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
opacity: 0.5;
line-height: 1;
}
.tree-cat-btn:hover {
opacity: 1;
}
.tree-cat-editor {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px 4px 24px;
background: var(--bg-card, #f0f0f0);
border-bottom: 1px solid var(--border-color, #ddd);
}
.tree-cat-close {
background: none;
border: none;
cursor: pointer;
font-size: 11px;
color: var(--text-muted, #999);
padding: 0 4px;
}
.tree-cat-close:hover {
color: var(--text-main, #333);
}
</style>
@if (_showInfoOverlay)
{
<div class="splash-screen" @onclick="CloseInfoOverlay">
@ -269,10 +404,11 @@ else
private List<Project>? projects;
private List<FileTreeItem>? fileTree;
private int currentProjectId;
private string? currentProjectName;
private string? currentFilePath;
private string statusText = "Bereit";
private bool isBusy;
private double operationProgress;
private string copyProjectName = string.Empty;
private string copyProjectPath = string.Empty;
private Project? copyProjectSource;
private bool showRenameDialog;
private string renameNewName = string.Empty;
private FileTreeItem? renameTarget;
@ -280,19 +416,40 @@ else
private string newFileName = string.Empty;
private FileTreeItem? newFileParentDir;
private bool showCopyProjectDialog;
private string copyProjectName = string.Empty;
private string copyProjectPath = string.Empty;
private Project? copyProjectSource;
private Action? _opStateHandler;
private string? _editingCategoryPath;
private HashSet<string> _selectedFiles = new();
private bool _showExportDialog;
private string exportFileName = string.Empty;
private string exportFormat = "Html";
[Inject]
private NavigationManager Navigation { get; set; } = default!;
[Inject]
private IJSRuntime JS { get; set; } = default!;
[Inject]
private IExportService ExportService { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
_opStateHandler = () => InvokeAsync(StateHasChanged);
OpState.OnChange += _opStateHandler;
await SettingsService.InitializeAsync();
await Loc.InitAsync();
statusText = Loc["common.status.ready"];
OpState.SetStatus(Loc["common.status.ready"]);
Navigation.LocationChanged += OnLocationChanged;
// Startup Setup Check
_ = Task.Run(async () =>
{
var result = await SetupService.AnalyzeSettingsAsync();
if (!result.IsCorrect)
{
OpState.SetStatus("Ollama-Einstellungen suboptimal. Setup-Assistent empfohlen.", false);
}
});
}
private async Task LoadCurrentUser()
@ -332,7 +489,7 @@ else
if (!_isAuthenticated && Program.DebugAutoLogin)
{
var users = await SettingsService.GetUsersAsync();
var first = users.FirstOrDefault();
var first = users.FirstOrDefault(u => u.Role == UserRole.Administrator);
if (first != null)
{
await AuthService.AdminBypassLoginAsync(first.Id);
@ -355,6 +512,8 @@ else
public void Dispose()
{
if (_opStateHandler != null)
OpState.OnChange -= _opStateHandler;
Navigation.LocationChanged -= OnLocationChanged;
}
@ -376,10 +535,13 @@ else
if (currentProjectId > 0)
{
var project = await ProjectService.GetProjectAsync(currentProjectId);
currentProjectName = project?.Name;
await LoadFileTree();
}
else
{
currentProjectName = null;
fileTree = null;
}
}
@ -396,11 +558,27 @@ else
}
}
private void ToggleCategoryEditor(FileTreeItem item)
{
if (_editingCategoryPath == item.FullPath)
_editingCategoryPath = null;
else
_editingCategoryPath = item.FullPath;
}
private void OnCategoryChanged(FileTreeItem item, FileCategory newCategory)
{
item.Category = newCategory;
_editingCategoryPath = null;
}
private async Task LoadProjects()
{
try
{
projects = await ProjectService.GetAllProjectsAsync();
var currentUser = AuthService.CurrentUser;
int? filterUserId = currentUser?.Role == UserRole.Administrator ? null : currentUser?.Id;
projects = await ProjectService.GetAllProjectsAsync(filterUserId);
}
catch
{
@ -428,8 +606,8 @@ else
private void BeginCopyProject(Project project)
{
copyProjectSource = project;
copyProjectName = $"{project.Name} (Kopie)";
copyProjectPath = project.RootPath + "_Kopie";
copyProjectName = project.Name + " (Kopie)";
copyProjectPath = ProjectPathHelper.SuggestPath(project.Name + "_Kopie");
showCopyProjectDialog = true;
}
@ -446,13 +624,19 @@ else
if (copyProjectSource == null || string.IsNullOrWhiteSpace(copyProjectName) || string.IsNullOrWhiteSpace(copyProjectPath))
return;
if (!ProjectPathHelper.IsPathAllowed(copyProjectPath))
{
OpState.SetStatus($"⚠ Pfad nicht erlaubt. Verwende: {ProjectPathHelper.AllowedBasePath}", true);
showCopyProjectDialog = false;
return;
}
try
{
var userId = (await AuthService.GetCurrentUserAsync())?.Id;
if (userId == null) return;
statusText = $"{Loc["nav.status.project_copied"]} {copyProjectName}...";
isBusy = true;
OpState.SetStatus($"{Loc["nav.status.project_copied"]} {copyProjectName}...", true);
StateHasChanged();
var newId = await ProjectService.CopyProjectAsync(copyProjectSource.Id, copyProjectName, copyProjectPath, userId.Value);
@ -464,15 +648,15 @@ else
await SelectProject(newProject);
}
statusText = $"{Loc["nav.status.project_copied"]} {copyProjectName}";
OpState.SetStatus($"{Loc["nav.status.project_copied"]} {copyProjectName}", true);
}
catch (Exception ex)
{
statusText = $"{Loc["common.error"]} {ex.Message}";
OpState.SetStatus($"{Loc["common.error"]} {ex.Message}", true);
}
finally
{
isBusy = false;
OpState.SetStatus(OpState.StatusText, false);
showCopyProjectDialog = false;
copyProjectSource = null;
copyProjectName = string.Empty;
@ -523,6 +707,7 @@ else
activeSection = "files";
SettingsService.CurrentProjectId = project.Id;
currentProjectId = project.Id;
currentProjectName = project.Name;
SettingsService.CurrentFilePath = null;
await SettingsService.SaveLastProjectIdAsync(project.Id);
await SettingsService.LoadProjectSettingsAsync();
@ -560,7 +745,7 @@ else
var newPath = Path.Combine(dir, renameNewName);
if (File.Exists(newPath))
{
statusText = Loc["nav.error.file_exists"];
OpState.SetStatus(Loc["nav.error.file_exists"]);
return;
}
File.Move(renameTarget.FullPath, newPath);
@ -568,11 +753,11 @@ else
if (currentFilePath == renameTarget.FullPath)
SettingsService.CurrentFilePath = newPath;
statusText = $"{Loc["nav.status.renamed_to"]} {renameNewName}";
OpState.SetStatus($"{Loc["nav.status.renamed_to"]} {renameNewName}");
}
catch (Exception ex)
{
statusText = $"{Loc["common.error"]} {ex.Message}";
OpState.SetStatus($"{Loc["common.error"]} {ex.Message}");
}
finally
{
@ -609,15 +794,15 @@ else
path += ".md";
if (File.Exists(path))
{
statusText = Loc["nav.error.file_exists"];
OpState.SetStatus(Loc["nav.error.file_exists"]);
return;
}
File.WriteAllText(path, $"# {Path.GetFileNameWithoutExtension(path)}\n\n");
statusText = $"{Loc["nav.status.created"]} {Path.GetFileName(path)}";
OpState.SetStatus($"{Loc["nav.status.created"]} {Path.GetFileName(path)}");
}
catch (Exception ex)
{
statusText = $"{Loc["common.error"]} {ex.Message}";
OpState.SetStatus($"{Loc["common.error"]} {ex.Message}");
}
finally
{
@ -637,11 +822,54 @@ else
Navigation.NavigateTo($"/editor/file/{currentProjectId}?t={DateTime.Now.Ticks}");
}
public void SetStatus(string text, bool busy = false, double progress = 0)
private void ToggleFileSelection(string path)
{
statusText = text;
isBusy = busy;
operationProgress = progress;
StateHasChanged();
if (!_selectedFiles.Remove(path))
_selectedFiles.Add(path);
}
private async Task DoExport()
{
var format = exportFormat switch
{
"Html" => ExportFormat.Html,
"Markdown" => ExportFormat.Markdown,
"Zip" => ExportFormat.Zip,
"Docx" => ExportFormat.Docx,
"Pdf" => ExportFormat.Pdf,
_ => ExportFormat.Html
};
var ext = format switch
{
ExportFormat.Html => ".html",
ExportFormat.Markdown => ".md",
ExportFormat.Zip => ".zip",
ExportFormat.Docx => ".docx",
ExportFormat.Pdf => ".pdf",
_ => ".html"
};
var name = string.IsNullOrWhiteSpace(exportFileName)
? $"Export_{DateTime.Now:yyyyMMdd_HHmmss}"
: exportFileName;
var data = await ExportService.ExportAsync(currentProjectId, _selectedFiles.ToList(), format);
var mime = format switch
{
ExportFormat.Html => "text/html",
ExportFormat.Markdown => "text/markdown",
ExportFormat.Zip => "application/zip",
ExportFormat.Docx => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
ExportFormat.Pdf => "application/pdf",
_ => "text/html"
};
await JS.InvokeVoidAsync("downloadFile", $"{name}{ext}", Convert.ToBase64String(data), mime);
_showExportDialog = false;
_selectedFiles.Clear();
}
}

View file

@ -9,6 +9,19 @@ main {
flex: 1;
}
.main-header {
padding: 12px 24px;
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
}
.main-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;

View file

@ -0,0 +1,195 @@
@page "/db-admin"
@attribute [Authorize]
@inject IDbAdminService DbAdminSvc
@inject ILocalizationService Loc
@inject NavigationManager Navigation
@inject IAuthService AuthService
<PageTitle>Datenbank Administration - Author Buddy</PageTitle>
<div class="dbadmin-page">
<h3>Datenbank Administration</h3>
<div class="dbadmin-toolbar">
<select @bind="selectedCollection" @bind:after="LoadRecords">
@foreach (var c in collections)
{
<option value="@c.Name">@c.Name (@c.Count Einträge)</option>
}
</select>
<span style="font-size:12px;color:var(--border-color);margin-left:8px;">
@records.Count Einträge in "@selectedCollection"
</span>
</div>
@if (records.Count == 0)
{
<p style="padding:24px;color:var(--border-color);">Leere Sammlung.</p>
}
else
{
<div class="dbadmin-table-wrapper">
<table class="dbadmin-table">
<thead>
<tr>
<th>ID</th>
@foreach (var key in columnOrder)
{
<th>@key</th>
}
<th style="width:130px;">Aktionen</th>
</tr>
</thead>
<tbody>
@foreach (var record in records)
{
<tr>
<td><strong>@record.Id</strong></td>
@foreach (var key in columnOrder)
{
<td class="dbadmin-cell" title="@(record.Fields.GetValueOrDefault(key) ?? "(null)")">
@Truncate(record.Fields.GetValueOrDefault(key), 60)
</td>
}
<td class="dbadmin-actions">
<button class="btn btn-sm" @onclick="() => StartEdit(record)">Bearbeiten</button>
<button class="btn btn-sm btn-danger" @onclick="() => ConfirmDelete(record)">Löschen</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@if (!string.IsNullOrEmpty(statusMessage))
{
<div class="dbadmin-status">@statusMessage</div>
}
<button class="btn btn-secondary" @onclick='() => Navigation.NavigateTo("/settings")' style="margin-top:12px;">Zurück</button>
</div>
@if (editingRecord != null)
{
<div class="modal-overlay" @onclick="CancelEdit">
<div class="modal-dialog" @onclick:stopPropagation>
<div class="modal-title">Datensatz bearbeiten (ID: @editingRecord.Id) — @selectedCollection</div>
<div class="modal-body">
@foreach (var key in columnOrder)
{
var idx = key;
<div class="form-group">
<label>@key</label>
<input value="@(editValues.GetValueOrDefault(idx) ?? string.Empty)" @onchange="(ChangeEventArgs e) => { editValues[idx] = e.Value?.ToString() ?? string.Empty; }" />
</div>
}
</div>
<div class="modal-actions">
<button @onclick="CancelEdit">Abbrechen</button>
<button @onclick="SaveEdit" class="primary">Speichern</button>
</div>
</div>
</div>
}
@if (deletingRecord != null)
{
<div class="modal-overlay" @onclick="CancelDelete">
<div class="modal-dialog modal-sm" @onclick:stopPropagation>
<div class="modal-title">Löschen bestätigen</div>
<div class="modal-body">
<p>Datensatz <strong>ID @deletingRecord.Id</strong> aus <strong>@selectedCollection</strong> löschen?</p>
<p style="font-size:12px;color:var(--border-color);">Diese Aktion kann nicht rückgängig gemacht werden.</p>
</div>
<div class="modal-actions">
<button @onclick="CancelDelete">Abbrechen</button>
<button @onclick="DeleteRecord" class="btn-danger">Löschen</button>
</div>
</div>
</div>
}
@code {
private List<DbCollectionInfo> collections = new();
private string selectedCollection = "users";
private List<DbRecord> records = new();
private List<string> columnOrder = new();
private string statusMessage = string.Empty;
private DbRecord? editingRecord;
private Dictionary<string, string?> editValues = new();
private DbRecord? deletingRecord;
protected override void OnInitialized()
{
var user = AuthService.CurrentUser;
if (user?.Role != UserRole.Administrator)
{
Navigation.NavigateTo("/");
return;
}
collections = DbAdminSvc.GetCollections();
if (collections.Count > 0)
selectedCollection = collections[0].Name;
LoadRecords();
}
private void LoadRecords()
{
records = DbAdminSvc.GetAll(selectedCollection);
columnOrder = records.Count > 0
? records[0].Fields.Keys.ToList()
: new List<string>();
statusMessage = string.Empty;
}
private void StartEdit(DbRecord record)
{
editingRecord = record;
editValues = new Dictionary<string, string?>(record.Fields);
}
private void CancelEdit()
{
editingRecord = null;
editValues.Clear();
}
private void SaveEdit()
{
if (editingRecord == null) return;
var ok = DbAdminSvc.Update(selectedCollection, editingRecord.Id, editValues);
statusMessage = ok ? "Gespeichert." : "Fehler beim Speichern.";
editingRecord = null;
editValues.Clear();
LoadRecords();
}
private void ConfirmDelete(DbRecord record)
{
deletingRecord = record;
}
private void CancelDelete()
{
deletingRecord = null;
}
private void DeleteRecord()
{
if (deletingRecord == null) return;
var ok = DbAdminSvc.Delete(selectedCollection, deletingRecord.Id);
statusMessage = ok ? "Gelöscht." : "Fehler beim Löschen.";
deletingRecord = null;
LoadRecords();
}
private static string? Truncate(string? val, int max)
{
if (val == null) return null;
return val.Length > max ? val[..max] + "..." : val;
}
}

View file

@ -9,6 +9,10 @@
@inject IWikipediaService WikipediaService
@inject NavigationManager Navigation
@inject ILocalizationService Loc
@inject IAuthService AuthService
@inject OllamaSettings OllamaSettings
@inject OperationState OpState
@inject IJSRuntime JS
@implements IDisposable
@rendermode InteractiveServer
@ -21,6 +25,24 @@
@oninput="OnContentChanged"
value="@editorContent"
placeholder="@Loc["editor.placeholder.content"]"></textarea>
<div class="editor-format-toolbar">
<button class="fmt-btn" @onclick="() => InsertHeading(1)" title="Überschrift 1">H1</button>
<button class="fmt-btn" @onclick="() => InsertHeading(2)" title="Überschrift 2">H2</button>
<button class="fmt-btn" @onclick="() => InsertHeading(3)" title="Überschrift 3">H3</button>
<button class="fmt-btn" @onclick="() => InsertHeading(4)" title="Überschrift 4">H4</button>
<button class="fmt-btn" @onclick="() => InsertHeading(5)" title="Überschrift 5">H5</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" @onclick='() => WrapSelection("**", "**")' title="Fett"><b>B</b></button>
<button class="fmt-btn" @onclick='() => WrapSelection("*", "*")' title="Kursiv"><i>I</i></button>
<button class="fmt-btn" @onclick='() => WrapSelection("<u>", "</u>")' title="Unterstrichen"><u>U</u></button>
<span class="fmt-sep"></span>
<button class="fmt-btn" @onclick="() => InsertList(false)" title="Aufzählung">UL</button>
<button class="fmt-btn" @onclick="() => InsertList(true)" title="Nummerierte Liste">OL</button>
<span class="fmt-sep"></span>
<button class="fmt-btn" @onclick="() => IndentLines()" title="Einrücken">→</button>
<button class="fmt-btn" @onclick="() => OutdentLines()" title="Ausrücken">←</button>
</div>
<CopyCreateToolbar Text="@editorContent" DefaultFileName="editor_text" />
<div class="editor-toolbar">
<span style="flex: 1; font-size: 12px; color: var(--border-color); display:flex;align-items:center;gap:8px;">
@ -73,7 +95,8 @@
{
<div class="results-area">
<div class="diff-panel">
<h4>@Loc["editor.spellcheck.heading"]</h4>
<h4 style="display:inline">@Loc["editor.spellcheck.heading"]</h4>
<CopyCreateToolbar Text="@correctedText" DefaultFileName="korrigierter_text" />
<DiffView Original="@originalText" Corrected="@correctedText" />
<div class="diff-actions">
<button class="btn btn-primary" @onclick="ApplySpellCorrections">@Loc["editor.spellcheck.button.apply"]</button>
@ -177,7 +200,8 @@
else if (!string.IsNullOrEmpty(analysisResult) && !showVersionBrowser)
{
<div class="results-area">
<h4>@Loc["editor.result.heading"]</h4>
<h4 style="display:inline">@Loc["editor.result.heading"]</h4>
<CopyCreateToolbar Text="@analysisResult" />
<div class="result-text">@analysisResult</div>
</div>
}
@ -196,6 +220,20 @@
<div class="form-group">
<label>@Loc["editor.create_project.label.path"]</label>
<input @bind="newProjectPath" placeholder="@Loc["editor.create_project.placeholder.path"]" />
<small class="form-hint">Erlaubter Pfad: @ProjectPathHelper.AllowedBasePath</small>
</div>
<div class="form-group">
<label>Vorlage</label>
<div class="template-radio-group">
<label class="template-radio">
<input type="radio" name="template" value="@ProjectTemplate.Novel" checked="@(selectedTemplate == ProjectTemplate.Novel)" @onchange="() => selectedTemplate = ProjectTemplate.Novel" />
<span>📖 Roman</span>
</label>
<label class="template-radio">
<input type="radio" name="template" value="@ProjectTemplate.Technical" checked="@(selectedTemplate == ProjectTemplate.Technical)" @onchange="() => selectedTemplate = ProjectTemplate.Technical" />
<span>⚙️ Technische Vorlage (Requirements)</span>
</label>
</div>
</div>
</div>
<div class="modal-actions">
@ -228,9 +266,11 @@
private int currentProjectId;
private string? currentFile;
private bool showCreateDialog;
private string newProjectName = string.Empty;
private string newProjectPath = string.Empty;
private ProjectTemplate selectedTemplate = ProjectTemplate.Novel;
private Dictionary<string, bool> loadedArticles = new();
private bool showVersionDialog;
private string versionComment = string.Empty;
@ -266,6 +306,8 @@
if (string.Equals(Action, "newproject", StringComparison.OrdinalIgnoreCase))
{
newProjectName = string.Empty;
newProjectPath = ProjectPathHelper.SuggestPath("Projekt");
showCreateDialog = true;
}
}
@ -353,6 +395,16 @@
_contentModified = false;
statusText = $"{Loc["editor.status.loaded"]} {Path.GetFileName(filePath)}";
RefreshLatestVersion();
var styleModel = await SettingsService.GetStyleModelAsync();
if (!string.IsNullOrEmpty(styleModel))
{
var ctx = await OllamaService.GetModelContextWindowAsync(styleModel);
if (ctx.Success && editorContent.Length > ctx.ContextWindow * 0.8)
{
statusText = $"⚠ {Loc["editor.warning.context"]} ({editorContent.Length} Tokens ≈ {ctx.ContextWindow} Kontext)";
}
}
}
}
@ -402,15 +454,28 @@
try
{
originalText = editorContent;
correctedText = await OllamaService.QuickSpellCheckAsync(editorContent);
correctedText = "";
OpState.SetStatus(Loc["editor.status.checking_spelling"], true);
OpState.SetThought("");
await OllamaService.ChatStream(OllamaSettings.SpellingModel, editorContent, async chunk =>
{
correctedText += chunk;
OpState.AppendThought(chunk);
await InvokeAsync(StateHasChanged);
}, OllamaSettings.SpellingPrompt, OllamaSettings.SpellingTemp);
showSpellDiff = true;
OpState.SetStatus(Loc["common.status.ready"], false);
}
catch (Exception ex)
{
analysisResult = $"{Loc["common.error"]} {ex.Message}";
OpState.SetStatus($"{Loc["common.error"]} {ex.Message}", false);
}
finally
{
OpState.Reset();
statusText = Loc["common.status.ready"];
isChecking = false;
checkProgress = 0;
@ -583,13 +648,21 @@
{
if (string.IsNullOrWhiteSpace(newProjectName) || string.IsNullOrWhiteSpace(newProjectPath)) return;
if (!ProjectPathHelper.IsPathAllowed(newProjectPath))
{
showCreateDialog = false;
statusText = $"⚠ Pfad nicht erlaubt. Verwende: {ProjectPathHelper.AllowedBasePath}";
return;
}
isChecking = true;
statusText = Loc["editor.status.creating_project"];
try
{
Directory.CreateDirectory(newProjectPath);
var id = await ProjectService.CreateNewProjectAsync(newProjectName, newProjectPath);
var userId = AuthService.CurrentUser?.Id ?? 0;
var id = await ProjectService.CreateNewProjectAsync(newProjectName, newProjectPath, userId, selectedTemplate);
SettingsService.CurrentProjectId = id;
await SettingsService.LoadProjectSettingsAsync();
showCreateDialog = false;
@ -774,6 +847,21 @@
}
}
private async Task WrapSelection(string prefix, string suffix)
=> await JS.InvokeVoidAsync("EditorToolbar.wrapSelection", prefix, suffix);
private async Task InsertHeading(int level)
=> await JS.InvokeVoidAsync("EditorToolbar.insertHeading", level);
private async Task InsertList(bool ordered)
=> await JS.InvokeVoidAsync("EditorToolbar.insertList", ordered);
private async Task IndentLines()
=> await JS.InvokeVoidAsync("EditorToolbar.indentLines");
private async Task OutdentLines()
=> await JS.InvokeVoidAsync("EditorToolbar.outdentLines");
private void RefreshLatestVersion()
{
latestVersion = null;

240
Components/Pages/Help.razor Normal file
View file

@ -0,0 +1,240 @@
@page "/help"
@attribute [Authorize]
@inject ILocalizationService Loc
@inject NavigationManager Navigation
<PageTitle>@Loc["page.title.help"] - Author Buddy</PageTitle>
<div class="help-page">
<h3>@Loc["page.title.help"]</h3>
@if (Loc.CurrentLanguage == "en")
{
<section class="help-section">
<h4 class="help-section-title">📋 Overview</h4>
<p>AuthorBuddy is an AI-assisted writing environment for authors and requirements engineers. It connects to local LLMs (via Ollama or llama.cpp) to help you edit, analyze, and develop your texts.</p>
<p>Key features at a glance: project management with templates, Markdown editor with versioning, AI writing assistant with multiple modes, spell checking, style analysis, fact-checking (RAG), and Wikipedia research.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">📁 Managing Projects</h4>
<p><strong>Creating a project:</strong> Click <em>File → New Project</em> in the sidebar. Choose between the <strong>Novel</strong> template (for fiction with drafts, facts, characters) or the <strong>Technical</strong> template (for requirements documents with specifications and use cases).</p>
<p><strong>Opening a project:</strong> All your projects are listed under <em>Projects</em> in the sidebar. Click any project to open it in the editor.</p>
<p><strong>Copying a project:</strong> Use the context menu on a project to create a full copy including all files and settings.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">✍️ Editor</h4>
<p>The editor is your main workspace for writing Markdown texts.</p>
<ul>
<li><strong>Autosave:</strong> Your text is saved automatically every 2 seconds.</li>
<li><strong>Manual save:</strong> Press <kbd>Ctrl+S</kbd> or click <em>Save</em>.</li>
<li><strong>Versions:</strong> Click <em>Version</em> to create a snapshot. Browse and restore old versions via the <em>Versions</em> button.</li>
<li><strong>Diff view:</strong> Compare any two versions side by side.</li>
<li><strong>Spell check:</strong> Click <em>Spell Check</em> to have the AI correct spelling and grammar.</li>
<li><strong>Style check:</strong> Click <em>Style Check</em> to get AI feedback on your writing style.</li>
<li><strong>RAG search:</strong> After indexing your project, search through your own texts via the <em>Facts (RAG)</em> button.</li>
<li><strong>Wikipedia research:</strong> Search Wikipedia (German and English) directly from the editor. Save articles as facts.</li>
<li><strong>File categories:</strong> Tag your <code>.md</code> files with 🏷 in the file tree. Categories include Character, Exposé, Fact, and Document. The Exposé is automatically used as context in the assistant.</li>
</ul>
</section>
<section class="help-section">
<h4 class="help-section-title">🤖 AI Assistant</h4>
<p>The assistant offers multiple specialized modes. Select a mode from the dropdown, enter your text, and click <em>Execute</em>.</p>
<h5>Novel modes (always available):</h5>
<ul>
<li><strong>Editing:</strong> Style optimization — makes your text more vivid and engaging.</li>
<li><strong>Advocatus Diaboli:</strong> Takes a radical opposing view to reveal weaknesses in your argument.</li>
<li><strong>Hook Finder:</strong> Generates 5 captivating opening sentences.</li>
<li><strong>ELI5:</strong> Simplifies complex passages.</li>
<li><strong>Co-Author:</strong> Creative brainstorming for plot and character development.</li>
<li><strong>RAG:</strong> Factual query against your project's indexed content.</li>
</ul>
<h5>Technical modes (only with Technical template):</h5>
<ul>
<li><strong>Requirements Spec:</strong> Create a requirements specification per IREB standards.</li>
<li><strong>Functional Spec:</strong> Create a detailed functional specification.</li>
<li><strong>Use Cases:</strong> Define use cases with actors, flows, and exceptions.</li>
<li><strong>Stakeholder Analysis:</strong> Identify and analyze project stakeholders.</li>
<li><strong>Glossary:</strong> Create a technical glossary.</li>
<li><strong>Quality Requirements:</strong> Define quality requirements per ISO 25010.</li>
<li><strong>Risk Analysis:</strong> Identify and assess project risks.</li>
</ul>
<p><strong>Temperature:</strong> The temperature slider controls the AI's creativity (0 = precise/factual, 1 = balanced, 2 = chaotic/experimental).</p>
<p><strong>Custom prompts:</strong> Click <em>Edit Prompt</em> to modify the system prompt for the current session. <em>Reset</em> restores the default.</p>
<p><strong>Send to editor:</strong> Click the button to transfer the assistant's output directly into the editor.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">⚙️ Settings</h4>
<p>In the settings page you can configure your personal preferences:</p>
<ul>
<li><strong>Appearance:</strong> Choose between Light, Dark, and Retro (Windows 95) themes.</li>
<li><strong>Language:</strong> Switch between German and English.</li>
<li><strong>Prompts:</strong> Customize the system prompts and temperatures for each assistant mode.</li>
</ul>
<p>When a project is loaded, settings apply to that project. Without a project, your personal default settings are used.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">❓ FAQ</h4>
<dl>
<dt>How do I index my project for RAG search?</dt>
<dd>Open the editor with your project, then click <em>Index Project</em>. This scans all <code>.md</code> files and creates embeddings for similarity search.</dd>
<dt>Why can't I see the technical assistant modes?</dt>
<dd>Technical modes (Requirements Spec, Use Cases, etc.) only appear when you have a project with the <strong>Technical</strong> template open.</dd>
<dt>Why are some file categories disabled?</dt>
<dd>Exposé and Document categories cannot be combined with other categories. Only one file per project can be the Exposé.</dd>
<dt>How do I set up 2FA?</dt>
<dd>Go to <em>Settings → Set up 2FA</em>, scan the QR code with an authenticator app, and enter a verification code.</dd>
</dl>
</section>
}
else
{
<section class="help-section">
<h4 class="help-section-title">📋 Übersicht</h4>
<p>AuthorBuddy ist eine KI-gestützte Schreibumgebung für Autoren und Requirements Engineers. Sie verbindet sich mit lokalen LLMs (via Ollama oder llama.cpp) und hilft dir, Texte zu bearbeiten, zu analysieren und weiterzuentwickeln.</p>
<p>Die wichtigsten Funktionen auf einen Blick: Projektverwaltung mit Vorlagen, Markdown-Editor mit Versionierung, KI-Assistent mit mehreren Modi, Rechtschreibprüfung, Stilanalyse, Faktenabfrage (RAG) und Wikipedia-Recherche.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">📁 Projekte verwalten</h4>
<p><strong>Projekt anlegen:</strong> Klicke in der Seitenleiste auf <em>Datei → Neues Projekt</em>. Wähle zwischen der <strong>Roman</strong>-Vorlage (für Belletristik mit Drafts, Fakten, Charakteren) oder der <strong>Technischen</strong> Vorlage (für Anforderungsdokumente mit Lastenheft und Use Cases).</p>
<p><strong>Projekt öffnen:</strong> Alle Projekte werden unter <em>Projekte</em> in der Seitenleiste aufgelistet. Klicke auf ein Projekt, um es im Editor zu öffnen.</p>
<p><strong>Projekt kopieren:</strong> Nutze das Kontextmenü eines Projekts, um eine vollständige Kopie inkl. aller Dateien und Einstellungen zu erstellen.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">✍️ Editor</h4>
<p>Der Editor ist dein zentraler Arbeitsbereich zum Schreiben von Markdown-Texten.</p>
<ul>
<li><strong>Autosave:</strong> Dein Text wird automatisch alle 2 Sekunden gespeichert.</li>
<li><strong>Manuell speichern:</strong> Drücke <kbd>Strg+S</kbd> oder klicke auf <em>Speichern</em>.</li>
<li><strong>Versionierung:</strong> Klicke auf <em>Version</em>, um einen Snapshot zu erstellen. Alte Versionen kannst du über den <em>Versionen</em>-Button durchsuchen und wiederherstellen.</li>
<li><strong>Diff-Ansicht:</strong> Vergleiche zwei Versionen nebeneinander.</li>
<li><strong>Rechtschreibprüfung:</strong> Klicke auf <em>Rechtschreibung prüfen</em>, um Rechtschreib- und Grammatikfehler korrigieren zu lassen.</li>
<li><strong>Stilanalyse:</strong> Klicke auf <em>Stil prüfen</em>, um KI-Feedback zu deinem Schreibstil zu erhalten.</li>
<li><strong>RAG-Suche:</strong> Nach dem Indexieren deines Projekts kannst du über <em>Fakten suchen (RAG)</em> in deinen eigenen Texten suchen.</li>
<li><strong>Wikipedia-Recherche:</strong> Suche direkt aus dem Editor auf Wikipedia (DE+EN). Speichere Artikel als Fakten.</li>
<li><strong>Datei-Kategorien:</strong> Markiere deine <code>.md</code>-Dateien im Dateibaum mit 🏷. Kategorien: Charakter, Exposé, Fakt, Dokument. Das Exposé wird automatisch als Kontext im Assistenten verwendet.</li>
</ul>
</section>
<section class="help-section">
<h4 class="help-section-title">🤖 KI-Assistent</h4>
<p>Der Assistent bietet verschiedene spezialisierte Modi. Wähle einen Modus aus dem Dropdown, gib deinen Text ein und klicke auf <em>Ausführen</em>.</p>
<h5>Roman-Modi (immer verfügbar):</h5>
<ul>
<li><strong>Lektorat:</strong> Stiloptimierung macht den Text lebendiger und anschaulicher.</li>
<li><strong>Advocatus Diaboli:</strong> Radikale Gegenposition, um Schwachstellen aufzuzeigen.</li>
<li><strong>Hook-Finder:</strong> Generiert 5 fesselnde Einstiegssätze.</li>
<li><strong>ELI5:</strong> Vereinfacht komplexe Passagen.</li>
<li><strong>Co-Author:</strong> Kreatives Brainstorming für Handlung und Charaktere.</li>
<li><strong>RAG:</strong> Faktenabfrage aus dem projekteigenen Index.</li>
</ul>
<h5>Technische Modi (nur bei technischer Vorlage):</h5>
<ul>
<li><strong>Lastenheft:</strong> Erstellung eines Lastenhefts nach IREB-Standard.</li>
<li><strong>Pflichtenheft:</strong> Detailliertes Pflichtenheft mit technischer Umsetzung.</li>
<li><strong>Use Cases:</strong> Definition von Anwendungsfällen mit Akteuren und Abläufen.</li>
<li><strong>Stakeholder-Analyse:</strong> Identifikation und Analyse aller Projektbeteiligten.</li>
<li><strong>Glossar:</strong> Erstellung eines Fachglossars.</li>
<li><strong>Qualitätsanforderungen:</strong> Definition nach ISO 25010.</li>
<li><strong>Risikoanalyse:</strong> Identifikation und Bewertung von Projektrisiken.</li>
</ul>
<p><strong>Temperatur:</strong> Der Temperatur-Regler beeinflusst die Kreativität der KI (0 = präzise/faktentreu, 1 = ausgewogen, 2 = chaotisch/experimentell).</p>
<p><strong>Prompt bearbeiten:</strong> Klicke auf <em>Prompt bearbeiten</em>, um den System-Prompt für die aktuelle Session anzupassen. <em>Zurücksetzen</em> lädt den Standard-Prompt.</p>
<p><strong>An Editor übermitteln:</strong> Der Button überträgt die Ausgabe des Assistenten direkt in den Editor.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">⚙️ Einstellungen</h4>
<p>In den Einstellungen kannst du deine persönlichen Präferenzen konfigurieren:</p>
<ul>
<li><strong>Design:</strong> Wähle zwischen Hell, Dunkel und Retro (Windows 95).</li>
<li><strong>Sprache:</strong> Wechsle zwischen Deutsch und Englisch.</li>
<li><strong>Prompts:</strong> Passe die System-Prompts und Temperaturen für jeden Assistenten-Modus an.</li>
</ul>
<p>Wenn ein Projekt geladen ist, gelten die Einstellungen für dieses Projekt. Ohne Projekt werden deine persönlichen Standard-Einstellungen verwendet.</p>
</section>
<section class="help-section">
<h4 class="help-section-title">❓ FAQ</h4>
<dl>
<dt>Wie indexiere ich mein Projekt für die RAG-Suche?</dt>
<dd>Öffne den Editor mit deinem Projekt und klicke auf <em>Projekt indexieren</em>. Dabei werden alle <code>.md</code>-Dateien gescannt und Embeddings für die Ähnlichkeitssuche erstellt.</dd>
<dt>Warum sehe ich die technischen Assistenten-Modi nicht?</dt>
<dd>Die technischen Modi (Lastenheft, Use Cases usw.) erscheinen nur, wenn du ein Projekt mit der <strong>Technischen</strong> Vorlage geöffnet hast.</dd>
<dt>Warum sind manche Datei-Kategorien deaktiviert?</dt>
<dd>Exposé und Dokument können nicht mit anderen Kategorien kombiniert werden. Pro Projekt kann nur eine Datei das Exposé sein.</dd>
<dt>Wie richte ich 2FA ein?</dt>
<dd>Gehe zu <em>Einstellungen → 2FA einrichten</em>, scanne den QR-Code mit einer Authenticator-App und gib einen Bestätigungscode ein.</dd>
</dl>
</section>
}
<button class="btn btn-secondary" @onclick='() => Navigation.NavigateTo("/")' style="margin-top:24px;">← @(Loc.CurrentLanguage == "en" ? "Back to Dashboard" : "Zurück zum Dashboard")</button>
</div>
<style>
.help-page {
max-width: 800px;
margin: 0 auto;
padding: 24px;
}
.help-section {
margin-bottom: 28px;
}
.help-section-title {
font-size: 16px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color);
}
.help-page p {
margin: 6px 0;
line-height: 1.6;
}
.help-page ul {
margin: 6px 0;
padding-left: 20px;
}
.help-page li {
margin: 3px 0;
line-height: 1.5;
}
.help-page dl dt {
font-weight: bold;
margin-top: 10px;
}
.help-page dl dd {
margin-left: 0;
margin-top: 2px;
font-size: 13px;
}
.help-page kbd {
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
font-size: 12px;
background: var(--menu-bg);
}
.help-page h5 {
margin: 10px 0 4px;
font-size: 13px;
}
</style>

View file

@ -2,26 +2,43 @@
@attribute [Authorize]
@inject IOllamaService OllamaService
@inject ISettingsService SettingsService
@inject IRagService RagService
@inject IProjectService ProjectService
@inject NavigationManager Navigation
@inject ILocalizationService Loc
@rendermode InteractiveServer
@inject OperationState OpState
<PageTitle>@Loc["page.title.assistant"]</PageTitle>
<div class="assistant-page">
<div class="assistant-toolbar">
<select @bind="selectedMode">
<select value="@selectedMode" @onchange="OnModeChanged">
<option value="lektorat">@Loc["assistant.mode.lektorat"]</option>
<option value="advocatus">@Loc["assistant.mode.advocatus"]</option>
<option value="hookfinder">@Loc["assistant.mode.hookfinder"]</option>
<option value="eli5">@Loc["assistant.mode.eli5"]</option>
<option value="coauthor">@Loc["assistant.mode.coauthor"]</option>
<option value="rag">@Loc["assistant.mode.rag"]</option>
@if (currentTemplate == ProjectTemplate.Technical)
{
<option disabled style="font-style:italic;">── Requirements ──</option>
<option value="tech_lastenheft">@Loc["assistant.mode.tech_lastenheft"]</option>
<option value="tech_pflichtenheft">@Loc["assistant.mode.tech_pflichtenheft"]</option>
<option value="tech_usecase">@Loc["assistant.mode.tech_usecase"]</option>
<option value="tech_stakeholder">@Loc["assistant.mode.tech_stakeholder"]</option>
<option value="tech_glossar">@Loc["assistant.mode.tech_glossar"]</option>
<option value="tech_qualitaet">@Loc["assistant.mode.tech_qualitaet"]</option>
<option value="tech_risiko">@Loc["assistant.mode.tech_risiko"]</option>
}
</select>
<button @onclick="RunAssistant" disabled="@(() => isRunning || string.IsNullOrWhiteSpace(inputText))">@Loc["assistant.button.execute"]</button>
@if (!string.IsNullOrEmpty(outputText))
{
<button @onclick="SendToEditor" class="primary">@Loc["assistant.button.send_to_editor"]</button>
}
<button @onclick='() => showPromptEditor = !showPromptEditor' class="btn-small">
@(showPromptEditor ? Loc["assistant.button.hide_prompt"] : Loc["assistant.button.edit_prompt"])
</button>
<span style="flex:1;font-size:12px;color:var(--border-color);text-align:right;">@statusText</span>
@if (isRunning)
{
@ -29,13 +46,30 @@
}
</div>
@if (showPromptEditor && selectedMode != "rag")
{
<div class="assistant-prompt-editor">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<strong style="font-size:13px;">@Loc["assistant.label.system_prompt"]</strong>
<button class="btn-small" @onclick="ResetPrompt">@Loc["assistant.button.reset_prompt"]</button>
</div>
<textarea class="assistant-prompt-textarea" @bind="promptOverride" rows="4"></textarea>
<div class="temp-slider" style="margin-top:6px;">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@temperatureOverride.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(temperatureOverride)" @oninput="OnTempChanged" />
</div>
</div>
}
<div class="assistant-panels">
<div class="assistant-input-panel">
<h4>@Loc["assistant.label.input_text"]</h4>
<h4 style="display:inline">@Loc["assistant.label.input_text"]</h4>
<CopyCreateToolbar Text="@inputText" DefaultFileName="assistent_eingabe" />
<textarea class="assistant-textarea" @bind="inputText" placeholder="@Loc["assistant.placeholder.input"]"></textarea>
</div>
<div class="assistant-output-panel">
<h4>@Loc["assistant.label.result"]</h4>
<h4 style="display:inline">@Loc["assistant.label.result"]</h4>
<CopyCreateToolbar Text="@outputText" DefaultFileName="assistent_ausgabe" />
<div class="assistant-output">
@if (!string.IsNullOrEmpty(outputText))
{
@ -62,10 +96,23 @@
private string statusText = string.Empty;
private bool isRunning;
private double progress;
private bool showPromptEditor;
private string promptOverride = string.Empty;
private double temperatureOverride = 0.7;
private ProjectTemplate currentTemplate = ProjectTemplate.Novel;
protected override async Task OnInitializedAsync()
{
statusText = Loc["common.status.ready"];
await LoadPromptForMode();
var pid = SettingsService.CurrentProjectId;
if (pid != null)
{
var project = await ProjectService.GetProjectAsync(pid.Value);
if (project != null)
currentTemplate = project.Template;
}
var pending = SettingsService.PendingAssistantInput;
if (!string.IsNullOrEmpty(pending))
@ -75,6 +122,55 @@
}
}
private async Task OnModeChanged(ChangeEventArgs e)
{
selectedMode = e.Value?.ToString() ?? "lektorat";
await LoadPromptForMode();
}
private async Task LoadPromptForMode()
{
if (selectedMode == "rag")
{
promptOverride = string.Empty;
temperatureOverride = 0.3;
return;
}
var (prompt, temp) = selectedMode switch
{
"lektorat" => (await SettingsService.GetLektorSystemPromptAsync() ?? string.Empty, await SettingsService.GetLektorTempAsync()),
"advocatus" => (await SettingsService.GetAdvocatusDiaboliPromptAsync() ?? string.Empty, await SettingsService.GetAdvocatusDiaboliTempAsync()),
"hookfinder" => (await SettingsService.GetHookFinderPromptAsync() ?? string.Empty, await SettingsService.GetHookFinderTempAsync()),
"eli5" => (await SettingsService.GetEli5PromptAsync() ?? string.Empty, await SettingsService.GetEli5TempAsync()),
"coauthor" => (await SettingsService.GetCoAuthorBrainstormingPromptAsync() ?? string.Empty, await SettingsService.GetCoAuthorTempAsync()),
"tech_lastenheft" => (await SettingsService.GetTechLastenheftPromptAsync() ?? string.Empty, await SettingsService.GetTechLastenheftTempAsync()),
"tech_pflichtenheft" => (await SettingsService.GetTechPflichtenheftPromptAsync() ?? string.Empty, await SettingsService.GetTechPflichtenheftTempAsync()),
"tech_usecase" => (await SettingsService.GetTechUseCasePromptAsync() ?? string.Empty, await SettingsService.GetTechUseCaseTempAsync()),
"tech_stakeholder" => (await SettingsService.GetTechStakeholderPromptAsync() ?? string.Empty, await SettingsService.GetTechStakeholderTempAsync()),
"tech_glossar" => (await SettingsService.GetTechGlossarPromptAsync() ?? string.Empty, await SettingsService.GetTechGlossarTempAsync()),
"tech_qualitaet" => (await SettingsService.GetTechQualitaetPromptAsync() ?? string.Empty, await SettingsService.GetTechQualitaetTempAsync()),
"tech_risiko" => (await SettingsService.GetTechRisikoPromptAsync() ?? string.Empty, await SettingsService.GetTechRisikoTempAsync()),
_ => (string.Empty, 0.0)
};
promptOverride = prompt;
temperatureOverride = temp;
}
private async Task ResetPrompt()
{
await LoadPromptForMode();
}
private void OnTempChanged(ChangeEventArgs e)
{
if (e.Value is string s && double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v))
temperatureOverride = Math.Round(v, 1);
}
private string Fmt(double v) => v.ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
private async Task RunAssistant()
{
if (string.IsNullOrWhiteSpace(inputText)) return;
@ -83,33 +179,91 @@
progress = 50;
outputText = string.Empty;
errorMessage = string.Empty;
statusText = Loc["assistant.status.processing"];
statusText = Loc["assistant.status.starting"];
OpState.SetStatus(Loc["assistant.status.starting"], true);
OpState.SetThought("");
try
{
var systemPrompt = selectedMode switch
if (selectedMode == "rag")
{
"lektorat" => await SettingsService.GetLektorSystemPromptAsync() ?? string.Empty,
"advocatus" => await SettingsService.GetAdvocatusDiaboliPromptAsync() ?? string.Empty,
"hookfinder" => await SettingsService.GetHookFinderPromptAsync() ?? string.Empty,
"eli5" => await SettingsService.GetEli5PromptAsync() ?? string.Empty,
_ => string.Empty
};
var projectId = SettingsService.CurrentProjectId;
if (projectId == null)
{
errorMessage = Loc["assistant.error.no_project"];
return;
}
if (string.IsNullOrEmpty(systemPrompt))
{
errorMessage = Loc["assistant.error.no_prompt"];
return;
OpState.SetStatus(Loc["assistant.status.searching"], true);
progress = 30;
var results = await RagService.SearchAsync(projectId.Value, inputText, 5);
if (results.Count == 0)
{
errorMessage = Loc["assistant.error.no_facts"];
return;
}
var facts = string.Join("\n\n---\n\n", results.Select(r => r.Text));
progress = 70;
OpState.SetStatus(Loc["assistant.status.processing"], true);
outputText = await OllamaService.AnswerBasedOnFactsAsync(inputText, facts);
OpState.SetThought(outputText);
statusText = Loc["assistant.status.done"];
OpState.SetStatus(Loc["assistant.status.done"], false);
}
else
{
var systemPrompt = promptOverride;
var model = await SettingsService.GetStyleModelAsync() ?? "llama3.2";
outputText = await OllamaService.Chat(model, inputText, systemPrompt);
statusText = Loc["assistant.status.done"];
if (string.IsNullOrEmpty(systemPrompt))
{
errorMessage = Loc["assistant.error.no_prompt"];
return;
}
if (selectedMode is "coauthor" or "hookfinder" or "lektorat")
{
var projectId = SettingsService.CurrentProjectId;
if (projectId != null)
{
var project = await ProjectService.GetProjectAsync(projectId.Value);
if (project != null)
{
// Use Exposé file from category system
var categories = await ProjectService.GetFileCategoriesAsync(projectId.Value);
var exposeFile = categories.FirstOrDefault(c => c.Value == FileCategory.Exposee);
if (!string.IsNullOrEmpty(exposeFile.Key) && File.Exists(exposeFile.Key))
{
var exposee = await File.ReadAllTextAsync(exposeFile.Key);
if (!string.IsNullOrWhiteSpace(exposee))
{
systemPrompt += "\n\n" + Loc["assistant.exposee.context"] + "\n" + exposee;
}
}
}
}
}
var model = await SettingsService.GetStyleModelAsync() ?? "llama3.2";
OpState.SetStatus(Loc["assistant.status.processing"], true);
statusText = Loc["assistant.status.processing"];
await OllamaService.ChatStream(model, inputText, async chunk =>
{
outputText += chunk;
OpState.AppendThought(chunk);
await InvokeAsync(StateHasChanged);
}, systemPrompt, temperatureOverride);
statusText = Loc["assistant.status.done"];
OpState.SetStatus(Loc["assistant.status.done"], false);
}
}
catch (Exception ex)
{
errorMessage = $"{Loc["common.error"]} {ex.Message}";
statusText = Loc["common.status.error"];
OpState.SetStatus($"{Loc["common.error"]} {ex.Message}", false);
}
finally
{

View file

@ -0,0 +1,174 @@
@page "/setup-assistant"
@inject IOllamaSetupService SetupService
@inject ISettingsService SettingsService
@inject OperationState OpState
@inject ILocalizationService Loc
@inject OllamaSettings CurrentSettings
<PageTitle>Ollama Setup Assistent</PageTitle>
<div class="setup-assistant-container">
<div class="setup-header">
<h1>Ollama Setup Assistent</h1>
<p>Prüfen Sie Ihre Einstellungen für eine optimale Performance.</p>
</div>
@if (recommendation == null)
{
<div class="setup-content centered">
<button class="btn btn-primary" @onclick="RunAnalysis">Analyse starten</button>
</div>
}
else
{
<div class="setup-content">
<div class="status-card @(recommendation.IsCorrect ? "status-success" : "status-warning")">
<h3>@recommendation.Message</h3>
</div>
<div class="analysis-details">
<h4>Analyse-Ergebnisse:</h4>
<ul>
@foreach (var finding in recommendation.Findings)
{
<li>@finding</li>
}
</ul>
</div>
@if (!recommendation.IsCorrect)
{
<div class="recommendation-table">
<h4>Empfohlene Einstellungen:</h4>
<table>
<thead>
<tr>
<th>Einstellung</th>
<th>Aktuell</th>
<th>Empfohlen</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ollama URL</td>
<td>@CurrentSettings.OllamaUrl</td>
<td>@recommendation.RecommendedSettings.OllamaUrl</td>
</tr>
<tr>
<td>Style Model</td>
<td>@CurrentSettings.StyleModel</td>
<td>@recommendation.RecommendedSettings.StyleModel</td>
</tr>
<tr>
<td>Embedding Model</td>
<td>@CurrentSettings.EmbedModel</td>
<td>@recommendation.RecommendedSettings.EmbedModel</td>
</tr>
<tr>
<td>Spelling Model</td>
<td>@CurrentSettings.SpellingModel</td>
<td>@recommendation.RecommendedSettings.SpellingModel</td>
</tr>
</tbody>
</table>
<div class="setup-actions">
<button class="btn btn-primary" @onclick="ApplySettings">Einstellungen übernehmen</button>
</div>
</div>
}
</div>
}
</div>
<style>
.setup-assistant-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: var(--bg-card);
border-radius: 8px;
box-shadow: var(--shadow);
color: var(--text-main);
}
.setup-header {
text-align: center;
margin-bottom: 2rem;
}
.setup-content.centered {
display: flex;
justify-content: center;
padding: 3rem;
}
.status-card {
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
text-align: center;
}
.status-success {
background: var(--bg-success);
color: var(--text-success);
border: 1px solid var(--border-success);
}
.status-warning {
background: var(--bg-warning);
color: var(--text-warning);
border: 1px solid var(--border-warning);
}
.analysis-details {
margin-bottom: 2rem;
}
.analysis-details ul {
list-style: disc;
padding-left: 1.5rem;
}
.recommendation-table table {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
.recommendation-table th, .recommendation-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border);
}
.setup-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
</style>
@code {
private SetupRecommendation? recommendation;
private bool _applying;
private async Task RunAnalysis()
{
recommendation = await SetupService.AnalyzeSettingsAsync();
}
private async Task ApplySettings()
{
if (recommendation?.RecommendedSettings == null || _applying) return;
_applying = true;
try
{
var rec = recommendation.RecommendedSettings;
await SettingsService.SaveOllamaUrlAsync(rec.OllamaUrl);
await SettingsService.SaveStyleModelAsync(rec.StyleModel);
await SettingsService.SaveRagModelAsync(rec.EmbedModel);
await SettingsService.SaveSpellingModelAsync(rec.SpellingModel);
OpState.SetStatus("Einstellungen wurden in der Datenbank gespeichert.", true);
recommendation = await SetupService.AnalyzeSettingsAsync();
}
finally
{
_applying = false;
}
}
}

View file

@ -1,5 +1,6 @@
@page "/settings"
@attribute [Authorize]
@rendermode InteractiveServer
@inject ISettingsService SettingsService
@inject IThemeService ThemeService
@inject IOllamaService OllamaService
@ -7,30 +8,34 @@
@inject IJSRuntime JSRuntime
@inject IProjectService ProjectService
@inject ILocalizationService Loc
@inject IAuthService AuthService
<PageTitle>@Loc["page.title.settings"] - Author Buddy</PageTitle>
<div class="settings-page">
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.user"]</h3>
<div class="form-group">
<label for="userSelect">@Loc["settings.user.label.active"]</label>
<select id="userSelect" value="@selectedUserId" @onchange="SwitchUser">
@foreach (var u in users)
{
<option value="@u.Id">@u.Name (@(u.Role == AuthorBuddy.Web.Data.UserRole.Administrator ? Loc["settings.user.role.admin"] : Loc["settings.user.role.author"]))</option>
}
</select>
</div>
<div class="form-group">
<label for="newUserName">@Loc["settings.user.label.new"]</label>
<div style="display: flex; gap: 8px;">
<input id="newUserName" @bind="newUserName" placeholder="@Loc["settings.user.placeholder.name"]" style="flex: 1;" />
<button class="btn btn-secondary" @onclick="AddUser">@Loc["settings.user.button.add"]</button>
@if (_isAdmin)
{
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.user"]</h3>
<div class="form-group">
<label for="userSelect">@Loc["settings.user.label.active"]</label>
<select id="userSelect" value="@selectedUserId" @onchange="SwitchUser">
@foreach (var u in users)
{
<option value="@u.Id">@u.Name (@(u.Role == AuthorBuddy.Web.Data.UserRole.Administrator ? Loc["settings.user.role.admin"] : Loc["settings.user.role.author"]))</option>
}
</select>
</div>
<div class="form-group">
<label for="newUserName">@Loc["settings.user.label.new"]</label>
<div style="display: flex; gap: 8px;">
<input id="newUserName" @bind="newUserName" placeholder="@Loc["settings.user.placeholder.name"]" style="flex: 1;" />
<button class="btn btn-secondary" @onclick="AddUser">@Loc["settings.user.button.add"]</button>
</div>
</div>
</div>
</div>
}
<div class="settings-section" style="margin-bottom:16px;">
<button class="btn btn-secondary" @onclick='() => Navigation.NavigateTo("/setup-2fa")'>@Loc["settings.user.link.2fa"]</button>
@ -50,57 +55,97 @@
<div class="settings-layout">
<div class="settings-column" >
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.models"]</h3>
<div class="form-group">
<label for="ragModel">@Loc["settings.models.label.rag"]</label>
<select id="ragModel" @bind="ragModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
@if (_isAdmin)
{
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.models"]</h3>
<div class="form-group">
<label for="ragModel">@Loc["settings.models.label.rag"]</label>
<select id="ragModel" @bind="ragModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
</div>
<div class="form-group">
<label for="styleModel">@Loc["settings.models.label.style"]</label>
<select id="styleModel" @bind="styleModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
</div>
<div class="form-group">
<label for="spellingModel">@Loc["settings.models.label.spellcheck"]</label>
<select id="spellingModel" @bind="spellingModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
</div>
</div>
<div class="form-group">
<label for="styleModel">@Loc["settings.models.label.style"]</label>
<select id="styleModel" @bind="styleModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
</div>
<div class="form-group">
<label for="spellingModel">@Loc["settings.models.label.spellcheck"]</label>
<select id="spellingModel" @bind="spellingModel">
@if (availableModels.Count == 0)
{
<option value="">@Loc["settings.models.empty"]</option>
}
@foreach (var m in availableModels)
{
<option value="@m.Name">@m.Name</option>
}
</select>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.ollama_server"]</h3>
<div class="form-group">
<label for="ollamaUrl">@Loc["settings.ollama.label.url"]</label>
<input id="ollamaUrl" @bind="ollamaUrl" placeholder="http://localhost:11434" />
<div class="form-hint">@Loc["settings.ollama.hint.url"]</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.ollama_server"]</h3>
<div class="form-group">
<label for="backendSelect">Backend</label>
<select id="backendSelect" @bind="selectedBackend">
<option value="Ollama">Ollama</option>
<option value="llama.cpp">llama.cpp</option>
</select>
</div>
<div class="form-group">
<label for="ollamaUrl">@Loc["settings.ollama.label.url"] (Ollama)</label>
<div class="input-with-button">
<input id="ollamaUrl" @bind="ollamaUrl" placeholder="http://localhost:11434" />
<button @onclick="TestConnection" class="btn-sm" disabled="@_isTestingConnection">@Loc["settings.ollama.test"]</button>
</div>
@if (_connectionTested)
{
<div class="ollama-status @(_connectionOk ? "ollama-ok" : "ollama-fail")">
@(_connectionOk
? $"✓ {_connectionLatency} ms {Loc["settings.ollama.online"]}"
: $"✗ {Loc["settings.ollama.offline"]}")
</div>
}
<div class="form-hint">@Loc["settings.ollama.hint.url"]</div>
</div>
<div class="form-group">
<label for="llamaCppUrl">URL (llama.cpp)</label>
<input id="llamaCppUrl" @bind="llamaCppUrl" placeholder="http://localhost:8080" />
<div class="form-hint">Nur aktiv wenn Backend auf "llama.cpp" gesetzt ist</div>
</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.diagnostics"]</h3>
<button @onclick="RunAllDiagnostics" class="btn-sm" disabled="@_isDiagnosing">@Loc["settings.diagnostics.run"]</button>
@if (_diagnosticsResults != null)
{
<div class="diagnostics-results">
@foreach (var line in _diagnosticsResults)
{
<div class="diagnostics-line">@line</div>
}
</div>
}
</div>
}
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.appearance"]</h3>
@ -126,12 +171,20 @@
</div>
</div>
<div class="settings-column" >
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.editor_prompt"]</h3>
@if (currentProjectTemplate == null || currentProjectTemplate == ProjectTemplate.Novel)
{
<div class="settings-column">
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.editor_prompt"]</h3>
<div class="form-group">
<textarea id="lektorPrompt" @bind="lektorPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@lektorTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(lektorTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => lektorTemp = v))" />
<div class="temp-category">@GetTempCategory(lektorTemp)</div>
<div class="temp-desc">@GetTempDescription(lektorTemp)</div>
</div>
</div>
<div class="settings-section">
@ -139,6 +192,12 @@
<div class="form-group">
<textarea id="factCheckTemplate" @bind="factCheckTemplate" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@factCheckTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(factCheckTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => factCheckTemp = v))" />
<div class="temp-category">@GetTempCategory(factCheckTemp)</div>
<div class="temp-desc">@GetTempDescription(factCheckTemp)</div>
</div>
</div>
<div class="settings-section">
@ -146,15 +205,30 @@
<div class="form-group">
<textarea id="spellingPrompt" @bind="spellingPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@spellingTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(spellingTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => spellingTemp = v))" />
<div class="temp-category">@GetTempCategory(spellingTemp)</div>
<div class="temp-desc">@GetTempDescription(spellingTemp)</div>
</div>
</div>
</div>
}
<div class="settings-column" >
@if (currentProjectTemplate == null || currentProjectTemplate == ProjectTemplate.Novel)
{
<div class="settings-column">
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.advocatus_diaboli"]</h3>
<div class="form-group">
<textarea id="advocatusDiaboliPrompt" @bind="advocatusDiaboliPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@advocatusDiaboliTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(advocatusDiaboliTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => advocatusDiaboliTemp = v))" />
<div class="temp-category">@GetTempCategory(advocatusDiaboliTemp)</div>
<div class="temp-desc">@GetTempDescription(advocatusDiaboliTemp)</div>
</div>
</div>
<div class="settings-section">
@ -162,6 +236,12 @@
<div class="form-group">
<textarea id="hookFinderPrompt" @bind="hookFinderPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@hookFinderTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(hookFinderTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => hookFinderTemp = v))" />
<div class="temp-category">@GetTempCategory(hookFinderTemp)</div>
<div class="temp-desc">@GetTempDescription(hookFinderTemp)</div>
</div>
</div>
<div class="settings-section">
@ -169,8 +249,124 @@
<div class="form-group">
<textarea id="eli5Prompt" @bind="eli5Prompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@eli5Temp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(eli5Temp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => eli5Temp = v))" />
<div class="temp-category">@GetTempCategory(eli5Temp)</div>
<div class="temp-desc">@GetTempDescription(eli5Temp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.coauthor_brainstorming"]</h3>
<div class="form-group">
<textarea id="coAuthorPrompt" @bind="coAuthorPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@coAuthorTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(coAuthorTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => coAuthorTemp = v))" />
<div class="temp-category">@GetTempCategory(coAuthorTemp)</div>
<div class="temp-desc">@GetTempDescription(coAuthorTemp)</div>
</div>
</div>
</div>
}
@if (currentProjectTemplate == null || currentProjectTemplate == ProjectTemplate.Technical)
{
<div class="settings-column">
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_lastenheft"]</h3>
<div class="form-group">
<textarea id="techLastenheftPrompt" @bind="techLastenheftPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techLastenheftTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techLastenheftTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techLastenheftTemp = v))" />
<div class="temp-category">@GetTempCategory(techLastenheftTemp)</div>
<div class="temp-desc">@GetTempDescription(techLastenheftTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_pflichtenheft"]</h3>
<div class="form-group">
<textarea id="techPflichtenheftPrompt" @bind="techPflichtenheftPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techPflichtenheftTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techPflichtenheftTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techPflichtenheftTemp = v))" />
<div class="temp-category">@GetTempCategory(techPflichtenheftTemp)</div>
<div class="temp-desc">@GetTempDescription(techPflichtenheftTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_usecase"]</h3>
<div class="form-group">
<textarea id="techUseCasePrompt" @bind="techUseCasePrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techUseCaseTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techUseCaseTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techUseCaseTemp = v))" />
<div class="temp-category">@GetTempCategory(techUseCaseTemp)</div>
<div class="temp-desc">@GetTempDescription(techUseCaseTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_stakeholder"]</h3>
<div class="form-group">
<textarea id="techStakeholderPrompt" @bind="techStakeholderPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techStakeholderTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techStakeholderTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techStakeholderTemp = v))" />
<div class="temp-category">@GetTempCategory(techStakeholderTemp)</div>
<div class="temp-desc">@GetTempDescription(techStakeholderTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_glossar"]</h3>
<div class="form-group">
<textarea id="techGlossarPrompt" @bind="techGlossarPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techGlossarTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techGlossarTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techGlossarTemp = v))" />
<div class="temp-category">@GetTempCategory(techGlossarTemp)</div>
<div class="temp-desc">@GetTempDescription(techGlossarTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_qualitaet"]</h3>
<div class="form-group">
<textarea id="techQualitaetPrompt" @bind="techQualitaetPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techQualitaetTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techQualitaetTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techQualitaetTemp = v))" />
<div class="temp-category">@GetTempCategory(techQualitaetTemp)</div>
<div class="temp-desc">@GetTempDescription(techQualitaetTemp)</div>
</div>
</div>
<div class="settings-section">
<h3 class="settings-section-title">@Loc["settings.section.tech_risiko"]</h3>
<div class="form-group">
<textarea id="techRisikoPrompt" @bind="techRisikoPrompt" rows="6"></textarea>
</div>
<div class="temp-slider">
<label class="temp-label">@Loc["settings.temperature.label"] <span class="temp-value">@techRisikoTemp.ToString("F1")</span></label>
<input type="range" min="0" max="2" step="0.1" value="@Fmt(techRisikoTemp)" @oninput="@((ChangeEventArgs e) => SetTemp(e, v => techRisikoTemp = v))" />
<div class="temp-category">@GetTempCategory(techRisikoTemp)</div>
<div class="temp-desc">@GetTempDescription(techRisikoTemp)</div>
</div>
</div>
</div>
}
</div>
@ -190,12 +386,36 @@
private string spellingModel = "phi3:mini";
private string selectedTheme = "light";
private string ollamaUrl = "http://localhost:11434";
private string llamaCppUrl = "http://localhost:8080";
private string selectedBackend = "Ollama";
private string lektorPrompt = string.Empty;
private string factCheckTemplate = string.Empty;
private string spellingPrompt = string.Empty;
private string advocatusDiaboliPrompt = string.Empty;
private string hookFinderPrompt = string.Empty;
private string eli5Prompt = string.Empty;
private string coAuthorPrompt = string.Empty;
private double lektorTemp = 0.7;
private double factCheckTemp = 0.3;
private double spellingTemp = 0.1;
private double advocatusDiaboliTemp = 0.9;
private double hookFinderTemp = 0.9;
private double eli5Temp = 0.5;
private double coAuthorTemp = 1.0;
private string techLastenheftPrompt = string.Empty;
private string techPflichtenheftPrompt = string.Empty;
private string techUseCasePrompt = string.Empty;
private string techStakeholderPrompt = string.Empty;
private string techGlossarPrompt = string.Empty;
private string techQualitaetPrompt = string.Empty;
private string techRisikoPrompt = string.Empty;
private double techLastenheftTemp = 0.3;
private double techPflichtenheftTemp = 0.3;
private double techUseCaseTemp = 0.4;
private double techStakeholderTemp = 0.5;
private double techGlossarTemp = 0.2;
private double techQualitaetTemp = 0.3;
private double techRisikoTemp = 0.5;
private string selectedLanguage = "de";
private string newProjectName = string.Empty;
private string newUserName = string.Empty;
@ -204,9 +424,20 @@
private List<UserEntity> users = new();
private int selectedUserId;
private string? currentProjectName;
private ProjectTemplate? currentProjectTemplate;
private bool _isAdmin;
private bool _connectionOk;
private long _connectionLatency;
private bool _connectionTested;
private bool _isTestingConnection;
private List<string>? _diagnosticsResults;
private bool _isDiagnosing;
protected override async Task OnInitializedAsync()
{
var currentUser = AuthService.CurrentUser;
_isAdmin = currentUser?.Role == UserRole.Administrator;
users = await SettingsService.GetUsersAsync();
var current = await SettingsService.GetCurrentUserAsync();
selectedUserId = current.Id;
@ -216,18 +447,22 @@
{
var project = await ProjectService.GetProjectAsync(pid.Value);
currentProjectName = project?.Name;
currentProjectTemplate = project?.Template;
}
await LoadUserSettings();
try
if (_isAdmin)
{
var models = await OllamaService.GetLocalModelsAsync();
availableModels = models.ToList();
}
catch
{
availableModels = new();
try
{
var models = await OllamaService.GetLocalModelsAsync();
availableModels = models.ToList();
}
catch
{
availableModels = new();
}
}
}
@ -238,12 +473,30 @@
spellingModel = await SettingsService.GetSpellingModelAsync() ?? "phi3:mini";
selectedTheme = await SettingsService.GetThemeAsync() ?? "light";
ollamaUrl = await SettingsService.GetOllamaUrlAsync() ?? "http://localhost:11434";
llamaCppUrl = await SettingsService.GetLlamaCppUrlAsync() ?? "http://localhost:8080";
selectedBackend = await SettingsService.GetBackendAsync() ?? "Ollama";
lektorPrompt = await SettingsService.GetLektorSystemPromptAsync() ?? string.Empty;
factCheckTemplate = await SettingsService.GetFactCheckTemplateAsync() ?? string.Empty;
spellingPrompt = await SettingsService.GetSpellingPromptAsync() ?? string.Empty;
advocatusDiaboliPrompt = await SettingsService.GetAdvocatusDiaboliPromptAsync() ?? string.Empty;
hookFinderPrompt = await SettingsService.GetHookFinderPromptAsync() ?? string.Empty;
eli5Prompt = await SettingsService.GetEli5PromptAsync() ?? string.Empty;
coAuthorPrompt = await SettingsService.GetCoAuthorBrainstormingPromptAsync() ?? string.Empty;
coAuthorTemp = await SettingsService.GetCoAuthorTempAsync();
techLastenheftPrompt = await SettingsService.GetTechLastenheftPromptAsync() ?? string.Empty;
techPflichtenheftPrompt = await SettingsService.GetTechPflichtenheftPromptAsync() ?? string.Empty;
techUseCasePrompt = await SettingsService.GetTechUseCasePromptAsync() ?? string.Empty;
techStakeholderPrompt = await SettingsService.GetTechStakeholderPromptAsync() ?? string.Empty;
techGlossarPrompt = await SettingsService.GetTechGlossarPromptAsync() ?? string.Empty;
techQualitaetPrompt = await SettingsService.GetTechQualitaetPromptAsync() ?? string.Empty;
techRisikoPrompt = await SettingsService.GetTechRisikoPromptAsync() ?? string.Empty;
techLastenheftTemp = await SettingsService.GetTechLastenheftTempAsync();
techPflichtenheftTemp = await SettingsService.GetTechPflichtenheftTempAsync();
techUseCaseTemp = await SettingsService.GetTechUseCaseTempAsync();
techStakeholderTemp = await SettingsService.GetTechStakeholderTempAsync();
techGlossarTemp = await SettingsService.GetTechGlossarTempAsync();
techQualitaetTemp = await SettingsService.GetTechQualitaetTempAsync();
techRisikoTemp = await SettingsService.GetTechRisikoTempAsync();
selectedLanguage = await SettingsService.GetLanguageAsync() ?? "de";
}
@ -268,23 +521,28 @@
private async Task SaveSettings()
{
await SettingsService.SaveOllamaUrlAsync(ollamaUrl);
OllamaService.Reconfigure(ollamaUrl);
if (!string.IsNullOrWhiteSpace(ragModel))
if (_isAdmin)
{
var ok = await OllamaService.TestEmbeddingAsync(ragModel);
if (!ok)
await SettingsService.SaveOllamaUrlAsync(ollamaUrl);
await SettingsService.SaveLlamaCppUrlAsync(llamaCppUrl);
await SettingsService.SaveBackendAsync(selectedBackend);
OllamaService.Reconfigure(ollamaUrl);
if (!string.IsNullOrWhiteSpace(ragModel))
{
statusMessage = selectedLanguage == "en"
? $"Model '{ragModel}' does not support embeddings. RAG model not saved."
: $"Modell '{ragModel}' unterstützt keine Embeddings. RAG-Modell nicht gespeichert.";
return;
var ok = await OllamaService.TestEmbeddingAsync(ragModel);
if (!ok)
{
statusMessage = selectedLanguage == "en"
? $"Model '{ragModel}' does not support embeddings. RAG model not saved."
: $"Modell '{ragModel}' unterstützt keine Embeddings. RAG-Modell nicht gespeichert.";
return;
}
}
await SettingsService.SaveRagModelAsync(ragModel);
await SettingsService.SaveStyleModelAsync(styleModel);
await SettingsService.SaveSpellingModelAsync(spellingModel);
}
await SettingsService.SaveRagModelAsync(ragModel);
await SettingsService.SaveStyleModelAsync(styleModel);
await SettingsService.SaveSpellingModelAsync(spellingModel);
await SettingsService.SaveThemeAsync(selectedTheme);
await SettingsService.SaveLektorSystemPromptAsync(lektorPrompt);
await SettingsService.SaveFactCheckTemplateAsync(factCheckTemplate);
@ -292,6 +550,28 @@
await SettingsService.SaveAdvocatusDiaboliPromptAsync(advocatusDiaboliPrompt);
await SettingsService.SaveHookFinderPromptAsync(hookFinderPrompt);
await SettingsService.SaveEli5PromptAsync(eli5Prompt);
await SettingsService.SaveCoAuthorBrainstormingPromptAsync(coAuthorPrompt);
await SettingsService.SaveTechLastenheftPromptAsync(techLastenheftPrompt);
await SettingsService.SaveTechPflichtenheftPromptAsync(techPflichtenheftPrompt);
await SettingsService.SaveTechUseCasePromptAsync(techUseCasePrompt);
await SettingsService.SaveTechStakeholderPromptAsync(techStakeholderPrompt);
await SettingsService.SaveTechGlossarPromptAsync(techGlossarPrompt);
await SettingsService.SaveTechQualitaetPromptAsync(techQualitaetPrompt);
await SettingsService.SaveTechRisikoPromptAsync(techRisikoPrompt);
await SettingsService.SaveLektorTempAsync(lektorTemp);
await SettingsService.SaveFactCheckTempAsync(factCheckTemp);
await SettingsService.SaveSpellingTempAsync(spellingTemp);
await SettingsService.SaveAdvocatusDiaboliTempAsync(advocatusDiaboliTemp);
await SettingsService.SaveHookFinderTempAsync(hookFinderTemp);
await SettingsService.SaveEli5TempAsync(eli5Temp);
await SettingsService.SaveCoAuthorTempAsync(coAuthorTemp);
await SettingsService.SaveTechLastenheftTempAsync(techLastenheftTemp);
await SettingsService.SaveTechPflichtenheftTempAsync(techPflichtenheftTemp);
await SettingsService.SaveTechUseCaseTempAsync(techUseCaseTemp);
await SettingsService.SaveTechStakeholderTempAsync(techStakeholderTemp);
await SettingsService.SaveTechGlossarTempAsync(techGlossarTemp);
await SettingsService.SaveTechQualitaetTempAsync(techQualitaetTemp);
await SettingsService.SaveTechRisikoTempAsync(techRisikoTemp);
await SettingsService.SaveLanguageAsync(selectedLanguage);
if (selectedLanguage != Loc.CurrentLanguage)
@ -300,4 +580,98 @@
statusMessage = Loc["settings.status.saved"];
await ThemeService.ApplyTheme(selectedTheme);
}
private string GetTempCategory(double temp)
{
if (temp <= 0.2) return Loc["settings.temperature.category.precise"];
if (temp <= 0.5) return Loc["settings.temperature.category.balanced"];
if (temp <= 1.2) return Loc["settings.temperature.category.creative"];
return Loc["settings.temperature.category.chaos"];
}
private string GetTempDescription(double temp)
{
if (temp <= 0.2) return Loc["settings.temperature.desc.precise"];
if (temp <= 0.5) return Loc["settings.temperature.desc.balanced"];
if (temp <= 1.2) return Loc["settings.temperature.desc.creative"];
return Loc["settings.temperature.desc.chaos"];
}
private string Fmt(double v) => v.ToString("F1", System.Globalization.CultureInfo.InvariantCulture);
private void SetTemp(ChangeEventArgs e, Action<double> setter)
{
if (e.Value is string s && double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v))
setter(Math.Round(v, 1));
}
private async Task TestConnection()
{
_isTestingConnection = true;
_connectionTested = false;
StateHasChanged();
OllamaService.Reconfigure(ollamaUrl);
var result = await OllamaService.TestConnectionAsync();
_connectionOk = result.Success;
_connectionLatency = result.LatencyMs;
_connectionTested = true;
_isTestingConnection = false;
}
private async Task RunAllDiagnostics()
{
_isDiagnosing = true;
_diagnosticsResults = new();
StateHasChanged();
OllamaService.Reconfigure(ollamaUrl);
var lang = selectedLanguage;
var results = new List<string>();
var conn = await OllamaService.TestConnectionAsync();
if (conn.Success)
results.Add(lang == "en" ? $"✓ Connection: {conn.LatencyMs} ms" : $"✓ Verbindung: {conn.LatencyMs} ms");
else
{
results.Add(lang == "en" ? "✗ Connection failed" : "✗ Verbindung fehlgeschlagen");
_diagnosticsResults = results;
_isDiagnosing = false;
return;
}
var models = new[] {
(Loc["settings.models.label.style"] ?? "Style", styleModel),
(Loc["settings.models.label.rag"] ?? "RAG", ragModel),
(Loc["settings.models.label.spellcheck"] ?? "Spelling", spellingModel)
};
foreach (var (label, model) in models)
{
if (string.IsNullOrWhiteSpace(model))
{
results.Add($"? {label}: {(!string.IsNullOrEmpty(lang) && lang == "en" ? "not configured" : "nicht konfiguriert")}");
continue;
}
var exists = await OllamaService.ModelExistsAsync(model);
if (!exists)
{
results.Add($"✗ {label} ({model}): {(!string.IsNullOrEmpty(lang) && lang == "en" ? "not found" : "nicht gefunden")}");
continue;
}
var latency = await OllamaService.MeasureLatencyAsync(model);
var latencyStr = latency >= 0 ? $"{latency} ms" : "—";
var ctx = await OllamaService.GetModelContextWindowAsync(model);
var ctxStr = ctx.Success ? $"{ctx.ContextWindow}" : "?";
results.Add($"✓ {label} ({model}): {latencyStr}, {(!string.IsNullOrEmpty(lang) && lang == "en" ? "Context" : "Kontext")}: {ctxStr}");
}
_diagnosticsResults = results;
_isDiagnosing = false;
}
}

472
DOCUMENTATION.md Normal file
View file

@ -0,0 +1,472 @@
# AuthorBuddy.Web Dokumentation
## Inhaltsverzeichnis
1. [Überblick](#1-überblick)
2. [Funktionsübersicht](#2-funktionsübersicht)
3. [Systemarchitektur](#3-systemarchitektur)
4. [Installation & Einrichtung](#4-installation--einrichtung)
5. [Bedienung](#5-bedienung)
- [Projektverwaltung](#51-projektverwaltung)
- [Editor](#52-editor)
- [Assistent](#53-assistent)
- [Einstellungen](#54-einstellungen)
- [Modellverwaltung](#55-modellverwaltung)
- [Benutzerverwaltung](#56-benutzerverwaltung)
- [Datenbank-Administration](#57-datenbank-administration)
6. [Datenbank](#6-datenbank)
7. [Themes & Lokalisierung](#7-themes--lokalisierung)
8. [LLM-Backends](#8-llm-backends)
9. [Technische Referenz](#9-technische-referenz)
---
## 1. Überblick
**AuthorBuddy.Web** ist eine webbasierte Schreibumgebung für Autoren mit KI-Unterstützung. Die Anwendung läuft als Blazor Interactive Server (.NET 10.0) und nutzt lokale Large Language Models (LLMs) über **Ollama** oder **llama.cpp** für Textanalyse, Lektorat, Faktenabfrage und kreative Zusammenarbeit.
Daten werden in einer eingebetteten **LiteDB**-Datenbank (NoSQL) gespeichert es wird kein externes Datenbanksystem benötigt.
### Zielgruppen
- **Autoren & Schriftsteller** Romanvorlage mit Kapitelverwaltung, Exposé, Charakterentwicklung
- **Requirements Engineers** Technische Vorlage für Lastenheft, Pflichtenheft, Use Cases uvm.
- **Administratoren** Benutzerverwaltung mit 2FA, Datenbank-Administration
---
## 2. Funktionsübersicht
| Funktion | Beschreibung |
|----------|-------------|
| **Projektverwaltung** | Zwei Vorlagen: Roman (mit Drafts, Fakten, Charakteren, Archiv) und Technisch (mit Anforderungen, Use Cases, Architektur, Glossar) |
| **Markdown-Editor** | Vollbild-Editor mit Autosave, Versionierung, Diff-Ansicht |
| **KI-Assistent** | Mehrere Modi: Lektorat, Advocatus Diaboli, Hook-Finder, ELI5, Co-Author, RAG-Faktenabfrage |
| **Technische Assistenten** | Lastenheft, Pflichtenheft, Use Cases, Stakeholder-Analyse, Glossar, Qualitätsanforderungen, Risikoanalyse |
| **Rechtschreibprüfung** | KI-gestützte Korrektur über LLM |
| **Stilanalyse** | KI-gestütztes Lektorat |
| **RAG (Retrieval-Augmented Generation)** | Volltext-Suche über Projektdateien mit Embedding-Vektoren |
| **Wikipedia-Recherche** | Integrierte Wikipedia-Suche (DE/EN) mit „Als Fakt speichern" |
| **Datei-Kategorien** | Markdown-Dateien können als Charakter, Exposé, Fakt oder Dokument getaggt werden |
| **Benutzerverwaltung** | Multi-User mit Passwort-Hashing und TOTP-2FA |
| **Modellverwaltung** | LLM-Modelle via Ollama herunterladen und verwalten |
| **Zwei Backends** | Ollama (vollständig) und llama.cpp (OpenAI-kompatibel) |
| **Mehrere Themes** | Hell, Dunkel, Windows 95 Retro |
| **Zweisprachig** | Deutsch (Standard) und Englisch |
| **DB-Admin** | Datenbank-Tabellen einsehen, bearbeiten und löschen (nur Admin) |
---
## 3. Systemarchitektur
### 3.1 Technologie-Stack
| Komponente | Technologie |
|------------|-------------|
| **Framework** | .NET 10.0 Blazor Interactive Server |
| **Sprache** | C# 13 |
| **UI** | Razor Components, CSS Custom Properties |
| **Datenbank** | LiteDB 5.0 (eingebettetes NoSQL-Dokumenten-DB) |
| **Text-Diff** | DiffPlex 1.9 |
| **Authentifizierung** | Cookie-basiert mit ClaimsPrincipal |
| **2FA** | TOTP (Time-based One-Time Password) |
### 3.2 Schichtenarchitektur
```
┌─────────────────────────────────────────────┐
│ Pages (.razor) │
│ Editor, Assistant, Settings, Models, ... │
├─────────────────────────────────────────────┤
│ Shared Components │
│ CategorySelector, CopyCreateToolbar, │
│ DiffView, RedirectToLogin │
├─────────────────────────────────────────────┤
│ Services │
│ AuthService │ OllamaService │
│ SettingsService │ ProjectService │
│ RagService │ WikipediaService │
│ ThemeService │ LocalizationService │
│ DbAdminService │ FileScannerService │
│ OllamaSetupService │
├─────────────────────────────────────────────┤
│ Backends (ILLMBackend) │
│ OllamaBackend │ LlamaCppBackend │
├─────────────────────────────────────────────┤
│ Daten (LiteDB) │
│ UserEntity, ProjectEntity, SettingEntity, │
│ FileCategoryEntity, DocumentVector, │
│ DocumentMetaEntity │
└─────────────────────────────────────────────┘
```
### 3.3 Abhängigkeits-Injektion (DI)
Die Anwendung verwendet das integrierte DI-System von ASP.NET Core:
- **Singleton** (`AddSingleton`): `ILiteDatabase`, `OllamaSettings`, `IOllamaService`, `ILLMBackend` (beide), `IProjectService`, `IRagService`, `IWikipediaService`, `IOllamaSetupService`, `IFileScannerService`, `OperationState`, `IDbAdminService`
- **Scoped** (`AddScoped`): `ISettingsService`, `IThemeService`, `IAuthService`, `ILocalizationService`, `CustomAuthStateProvider`
---
## 4. Installation & Einrichtung
### 4.1 Voraussetzungen
- **.NET 10.0 SDK** oder höher
- **Ollama** (empfohlen) oder **llama.cpp** für LLM-Unterstützung
- Ein moderner Webbrowser (Chrome, Edge, Firefox)
### 4.2 Schnellstart
```bash
# Repository klonen
git clone <repository-url>
cd AuthorBuddy.Web
# Bauen
dotnet build
# Starten
dotnet run
```
Die Anwendung ist standardmäßig unter `http://localhost:5000` erreichbar.
### 4.3 LLM-Modelle einrichten
1. Ollama installieren (https://ollama.com) oder llama.cpp konfigurieren
2. Modelle pullen, z.B.: `ollama pull llama3.2`, `ollama pull nomic-embed-text`
3. In der Anwendung unter **Einstellungen → Modelle** die Modelle auswählen
4. Verbindung testen mit dem **Test**-Button
### 4.4 Erstmaliger Start
Beim ersten Start wird automatisch ein Admin-Benutzer erstellt. Über den *Setup-Assistenten* (`/setup-assistant`) können die empfohlenen Einstellungen übernommen werden.
---
## 5. Bedienung
### 5.1 Projektverwaltung
**Neues Projekt anlegen:**
1. Klicke auf **Datei → Neues Projekt** in der Seitenleiste
2. Wähle eine Vorlage:
- **📖 Roman** Erzeugt Ordner für Drafts, Fakten, Charaktere, Archiv sowie Exposé- und Ideen-Vorlagen
- **⚙️ Technische Vorlage** Erzeugt Ordner für Anforderungen, Use Cases, Architektur, Glossar sowie Lastenheft- und Pflichtenheft-Vorlagen
3. Vergib einen Projektnamen und wähle einen Speicherort
4. Klicke auf **Erstellen**
**Projekt öffnen:**
In der Seitenleiste unter **Projekte** werden alle vorhandenen Projekte aufgelistet. Ein Klick öffnet das Projekt im Editor.
**Projekt kopieren:**
Rechtsklick (oder Kontextmenü) auf ein Projekt → **Projekt kopieren** erstellt eine vollständige Kopie inkl. Dateistruktur, Kategorien und projektspezifischer Einstellungen.
### 5.2 Editor
Der Editor (`/editor`) ist der zentrale Arbeitsbereich.
**Funktionen:**
| Funktion | Beschreibung |
|----------|-------------|
| **Text eingeben** | Markdown-Editor im Vollbildmodus |
| **Autosave** | Speichert automatisch alle 2 Sekunden |
| **Manuell speichern** | `Strg+S` oder Klick auf **Speichern** |
| **Versionierung** | Klick auf **Version** erstellt einen Snapshot im Unterordner `_versions/` |
| **Versionen durchsuchen** | **Versionen** → Liste aller Snapshots mit Kommentar |
| **Diff-Ansicht** | **Vergleichen** zeigt Side-by-Side-Diff zwischen Versionen |
| **Version wiederherstellen** | **Wiederherstellen** lädt eine alte Version in den Editor |
| **Stil prüfen** | KI-Analyse des Schreibstils über das Stil-Modell |
| **Rechtschreibung prüfen** | KI-Korrektur über das Spelling-Modell |
| **RAG-Suche** | Durchsucht indexierte Projektinhalte nach ähnlichen Passagen |
| **Wikipedia-Suche** | Durchsucht Wikipedia (DE+EN) und kann Ergebnisse als Fakten speichern |
| **Assistent** | Öffnet den KI-Assistenten mit dem aktuellen Text als Eingabe |
| **Projekt indexieren** | Baut den Vektorindex für RAG auf (nach Projektänderungen nötig) |
**Datei-Kategorien:**
Im Dateibaum können `.md`-Dateien mit Kategorien versehen werden:
- 👤 **Charakter** Personenprofil
- 📋 **Exposé** Handlungsübersicht (nur eine Datei pro Projekt)
- 📌 **Fakt** Hintergrundinformation
- 📄 **Dokument** Allgemeines Dokument
Kategorien werden im Dateibaum als Badge angezeigt und können über das 🏷-Symbol bearbeitet werden.
### 5.3 Assistent
Der KI-Assistent (`/assistant`) bietet mehrere Arbeitsmodi:
**Roman-Modi:** (immer verfügbar)
| Modus | Beschreibung | Temp. |
|-------|-------------|-------|
| **Lektorat** | Stilistische Textoptimierung lebendiger, anschaulicher Stil | 0.7 |
| **Advocatus Diaboli** | Radikale Gegenposition Schwachstellen in der Argumentation | 0.9 |
| **Hook-Finder** | 5 emotionale Einstiegssätze für den Text | 0.9 |
| **ELI5** | Vereinfachung komplexer Passagen | 0.5 |
| **Co-Author** | Kreative Weiterentwicklung von Handlung/Charakteren | 1.0 |
| **RAG** | Faktenabfrage aus dem projekteigenen Index | 0.3 |
**Technische Modi:** (nur bei technischer Projektvorlage sichtbar)
| Modus | Beschreibung | Temp. |
|-------|-------------|-------|
| **📋 Lastenheft** | Erstellung eines Lastenhefts nach IREB-Standard | 0.3 |
| **⚙️ Pflichtenheft** | Detailliertes Pflichtenheft mit technischer Umsetzung | 0.3 |
| **🔄 Use Cases** | UML-Use-Case-Modellierung mit Akteuren und Abläufen | 0.4 |
| **👥 Stakeholder-Analyse** | Identifikation und Analyse aller Projekt-Stakeholder | 0.5 |
| **📖 Glossar** | Fachglossar mit Definitionen und Synonymen | 0.2 |
| **✅ Qualitätsanforderungen** | Qualitätsanforderungen nach ISO 25010 | 0.3 |
| **⚠️ Risikoanalyse** | Identifikation und Bewertung von Projektrisiken | 0.5 |
**Prompt-Editor:**
Jeder Modus hat einen konfigurierbaren System-Prompt. Klicke auf **Prompt bearbeiten**, um den Prompt für die aktuelle Session anzupassen. Mit **Zurücksetzen** wird der Standard-Prompt aus den Einstellungen geladen.
**Temperatur:**
Der Temperatur-Regler beeinflusst die Kreativität des Modells:
- **0.00.2**: Präzise, deterministisch
- **0.30.5**: Ausgewogen, faktennah
- **0.61.2**: Kreativ, überraschende Wendungen
- **1.32.0**: Chaotisch, experimentell
**Exposé-Kontext:**
In den Modi Lektorat, Co-Author und Hook-Finder wird automatisch das per Kategorie markierte Exposé als Kontext in den Prompt eingefügt.
### 5.4 Einstellungen
Die Einstellungsseite (`/settings`) ist in vier Spalten organisiert:
| Spalte | Inhalt |
|--------|--------|
| Links (Admin) | Modelle, Ollama-Server, Diagnose, Design, Sprache |
| Mitte-Links | Lektor-System-Prompt, Fact-Check-Vorlage, Rechtschreibungs-Prompt |
| Mitte-Rechts | Advocatus Diaboli, Hook-Finder, ELI5, Co-Author |
| Rechts | Alle 7 technischen Prompts mit Temperatur |
**Projektkontext:**
Wenn ein Projekt geladen ist, werden die Einstellungen für dieses Projekt gespeichert. Ohne Projekt gelten die benutzerspezifischen Einstellungen.
### 5.5 Modellverwaltung
Die Modellverwaltung (`/models`) zeigt alle lokal vorhandenen Ollama-Modelle mit Größe und Änderungsdatum an. Neue Modelle können direkt heruntergeladen werden:
1. Modellname eingeben (z.B. `llama3.2`, `mistral`, `gemma4`)
2. Auf **Herunterladen** klicken
3. Der Fortschritt wird in Echtzeit angezeigt
### 5.6 Benutzerverwaltung
Die Benutzerverwaltung (`/user-management`) ist nur für Administratoren zugänglich:
- Liste aller Benutzer mit Rolle und Status
- Benutzer aktivieren/deaktivieren
- Benutzer löschen (der eigene Account kann nicht gelöscht werden)
**Zwei-Faktor-Authentifizierung (2FA):**
Jeder Benutzer kann 2FA einrichten:
1. Unter **Einstellungen → 2FA einrichten** QR-Code scannen
2. Geheimen Schlüssel notieren
3. Code zur Bestätigung eingeben
### 5.7 Datenbank-Administration
Die DB-Admin-Seite (`/db-admin`) ist Administratoren vorbehalten und erlaubt:
- Auswahl der Datenbank-Tabelle (users, projects, settings, fileCategories, documentMeta, vectors)
- Anzeige aller Datensätze in einer Tabelle
- Bearbeiten von Feldwerten (Öffnet Bearbeitungs-Modal)
- Löschen von Datensätzen (mit Bestätigungsdialog)
> **Achtung:** Hier können kritische Daten verändert oder gelöscht werden. Nur durchführen, wenn du genau weißt, was du tust.
---
## 6. Datenbank
Die Anwendung verwendet **LiteDB** eine eingebettete NoSQL-Dokumentendatenbank. Die Datenbankdatei (`author_brain.db`) liegt im Ausführungsverzeichnis der Anwendung.
### 6.1 Collections
| Collection | Entity | Zweck |
|------------|--------|-------|
| `users` | `UserEntity` | Benutzerkonten mit Passwort-Hash und 2FA-Secret |
| `projects` | `ProjectEntity` | Projekte mit Name, Pfad, Besitzer und Vorlagentyp |
| `settings` | `SettingEntity` | Key-Value-Einstellungen (global/benutzer-/projektspezifisch) |
| `fileCategories` | `FileCategoryEntity` | Datei-Kategorie-Zuordnungen |
| `documentMeta` | `DocumentMetaEntity` | Metadaten indexierter Dokumente (Hash, Änderungsdatum) |
| `vectors` | `DocumentVector` | Embedding-Vektoren für die RAG-Suche |
### 6.2 Einstellungshierarchie
Einstellungen werden mit absteigender Priorität aufgelöst:
1. **Projektspezifisch** (`UserId` + `ProjectId` gesetzt)
2. **Benutzerspezifisch** (`UserId` gesetzt, `ProjectId` null)
3. **Global** (beide null)
---
## 7. Themes & Lokalisierung
### 7.1 Themes
Drei CSS-Themes stehen zur Verfügung:
| Theme | Beschreibung |
|-------|-------------|
| **Hell** (Standard) | Helles Farbschema mit blauen Akzenten |
| **Dunkel** | Dark Mode, inspiriert von VS Code |
| **W95** | Windows 95 Retro-Stil |
Die Theme-Umschaltung erfolgt durch dynamisches Einfügen eines `<link>`-Elements in den Seitenkopf.
### 7.2 Lokalisierung
Unterstützte Sprachen:
- **Deutsch** (`de`) Standard
- **Englisch** (`en`)
Die Übersetzungen liegen als JSON-Dateien im Ordner `Resources/`. Die Sprachumschaltung erfolgt in den Einstellungen und wird sofort wirksam. Für nicht gefundene Schlüssel gibt es eingebaute deutsche Fallback-Texte.
---
## 8. LLM-Backends
### 8.1 Ollama (Standard)
- **Status**: Voll unterstützt
- **Modellverwaltung**: Ja (Pull, Delete)
- **API**: REST (`/api/generate`, `/api/embeddings`, `/api/tags`, `/api/pull`, `/api/delete`, `/api/show`)
- **Standard-URL**: `http://localhost:11434`
### 8.2 llama.cpp
- **Status**: Basisfunktionen unterstützt
- **Modellverwaltung**: Nein
- **API**: OpenAI-kompatibel (`/v1/chat/completions`, `/v1/models`) + `/embedding`, `/slots`
- **Standard-URL**: `http://localhost:8080`
Voreingestellte Modelle (in `OllamaSettings`):
| Modell | Zweck |
|--------|-------|
| `gemma4:e4b` | Stil-Analyse (Lektorat) |
| `nomic-embed-text` | Embeddings (RAG) |
| `phi3:mini` | Rechtschreibprüfung |
---
## 9. Technische Referenz
### 9.1 Routen
| Route | Seite | Zugriff |
|-------|-------|---------|
| `/` | Dashboard | Authentifiziert |
| `/login` | Anmeldung | Öffentlich |
| `/register` | Registrierung | Öffentlich |
| `/editor` | Editor | Authentifiziert |
| `/editor/index/{id}` | Editor mit Projekt | Authentifiziert |
| `/editor/newproject` | Editor mit Erstell-Dialog | Authentifiziert |
| `/assistant` | KI-Assistent | Authentifiziert |
| `/settings` | Einstellungen | Authentifiziert |
| `/models` | Modellverwaltung | Authentifiziert |
| `/user-management` | Benutzerverwaltung | Authentifiziert |
| `/setup-2fa` | 2FA einrichten | Authentifiziert |
| `/setup-assistant` | Setup-Assistent | Authentifiziert |
| `/db-admin` | DB-Administration | Authentifiziert (Admin) |
| `/not-found` | 404 | Öffentlich |
| `/Error` | 500 | Öffentlich |
### 9.2 NuGet-Pakete
- **DiffPlex** 1.9.0 Text-Differencing für die Diff-Ansicht
- **LiteDB** 5.0.21 Eingebettete NoSQL-Dokumentendatenbank
### 9.3 Schlüssel-Klassen
| Namespace | Klasse | Zweck |
|-----------|--------|-------|
| `Models` | `Constants` | App-Konstanten (DB-Name, Ordnerstruktur) |
| `Models` | `OllamaSettings` | Zentrale Konfiguration (Singleton) |
| `Models` | `ProjectTemplate` | Vorlagentyp (Novel, Technical) |
| `Models` | `FileCategory` | Datei-Kategorien als Flags-Enum |
| `Models` | `FileTreeItem` | Baumstruktur für Projektdateien |
| `Data` | `UserEntity` | Benutzer-DB-Entity |
| `Data` | `ProjectEntity` | Projekt-DB-Entity |
| `Data` | `SettingEntity` | Einstellungs-DB-Entity |
| `Data` | `DocumentVector` | Embedding-Vektor-DB-Entity |
| `Services` | `AuthService` | Authentifizierung mit 2FA |
| `Services` | `SettingsService` | Einstellungs-Verwaltung |
| `Services` | `ProjectService` | Projekt-CRUD |
| `Services` | `OllamaService` | LLM-Kommunikation (Fassade) |
| `Services` | `RagService` | RAG-Indexierung und -Suche |
| `Services` | `DbAdminService` | DB-Administration |
### 9.4 Wichtige Konstanten
```csharp
Constants.DbFile // "author_brain.db"
Constants.SubFolders // Standard-Ordnerstruktur (Referenz)
```
Projekt-Ordnerstruktur bei **Roman**-Vorlage:
```
{rootPath}/
├── 00_Drafts/
├── 01_Fakten/
├── 02_Characters/
├── 03_Archive/
├── 00_Projekt_Info.md
├── Die Handlung (Das Exposé).md
└── Ideen Sammlung.md
```
Projekt-Ordnerstruktur bei **Technischer** Vorlage:
```
{rootPath}/
├── 00_Anforderungen/
├── 01_UseCases/
├── 02_Architektur/
├── 03_Glossar/
├── 00_Projekt_Info.md
├── Lastenheft.md
└── Pflichtenheft.md
```
---
## 10. Entwicklung & Erweiterung
### 10.1 Neuen Assistenten-Modus hinzufügen
1. **Prompt-Default** in `Models/OllamaSettings.cs` (DE + EN + Temp)
2. **Settings-Key** in `Services/SettingsService.cs` (Key-Konstante + Getter/Setter)
3. **Interface-Methoden** in `Services/ISettingsService.cs`
4. **Settings-UI** in `Components/Pages/Settings.razor`
5. **Mode-Dropdown** + `LoadPromptForMode` in `Components/Pages/OllamaAssistant.razor`
6. **Lokalisierung** in `Resources/de.json` + `Resources/en.json`
7. **DB-Seeding** in `SettingsService.InitializeAsync()` + `LoadProjectSettingsAsync()`
### 10.2 Neues Daten-Modell hinzufügen
1. Entity-Klasse in `Data/` anlegen
2. Collection-Namen in `Services/DbAdminService.KnownCollections` aufnehmen
3. Service-Methoden für CRUD-Operationen schreiben
4. Bei Bedarf UI-Komponente erstellen
---
*Dokumentation erstellt am 31.05.2026*

View file

@ -0,0 +1,11 @@
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Data;
public class FileCategoryEntity
{
public int Id { get; set; }
public int ProjectId { get; set; }
public string FilePath { get; set; } = string.Empty;
public FileCategory Category { get; set; } = FileCategory.None;
}

View file

@ -1,3 +1,5 @@
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Data;
public class ProjectEntity
@ -5,5 +7,7 @@ public class ProjectEntity
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string RootPath { get; set; } = string.Empty;
public int UserId { get; set; }
public DateTime? LastOpened { get; set; }
public ProjectTemplate Template { get; set; }
}

259
INSTALL.md Normal file
View file

@ -0,0 +1,259 @@
# AuthorBuddy Installation auf Ubuntu VPS
## Voraussetzungen
- Ubuntu 22.04 oder 24.04 LTS
- Root-Zugriff oder `sudo`
- Mindestens 8 GB RAM, 4 Kerne (empfohlen für lokale LLMs)
- ca. 15 GB freier Speicher für Ollama + Modelle
---
## 1. System aktualisieren & Abhängigkeiten
```bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl git ufw
```
## 2. .NET 10 SDK / Runtime installieren
```bash
# Microsoft-Paketquelle registrieren
sudo apt install -y wget
wget https://dot.net/v1/dotnet-install.sh -O /tmp/dotnet-install.sh
chmod +x /tmp/dotnet-install.sh
# .NET 10 SDK (zum Build)
sudo /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet
# Symlink für globale Nutzung
sudo ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet
# Prüfen
dotnet --version
# => 10.0.xxx
```
> Falls nur Runtime benötigt wird: `--channel 10.0 --runtime aspnetcore`
## 3. Ollama installieren
```bash
curl -fsSL https://ollama.com/install.sh | sh
# Dienst starten
sudo systemctl enable ollama
sudo systemctl start ollama
# Prüfen
ollama --version
# Port prüfen (default 11434)
ss -tlnp | grep 11434
```
### Modelle pullen
Für den Betrieb werden mindestens 2 Modelle empfohlen:
```bash
# Standard-Modell für Lektorat, Assistent, Stilanalyse
ollama pull llama3.2:3b
# Modell für Rechtschreibprüfung (kleiner, schneller)
ollama pull llama3.2:1b
```
Größere Modelle (7B13B) liefern bessere Texte. Beispiele:
```bash
ollama pull llama3.1:8b
ollama pull dolphin-mistral:7b
```
**Übersicht Speicherverbrauch:**
| Modell | RAM/VRAM |
|--------|----------|
| llama3.2:1b | ~1 GB |
| llama3.2:3b | ~2,5 GB |
| llama3.1:8b | ~5,5 GB |
| dolphin-mistral:7b | ~5 GB |
---
## 4. App bauen & deployen
```bash
# Quellcode auf den Server kopieren (z.B. via SCP oder Git)
git clone https://github.com/dein-repo/AuthorBuddy.Web /opt/AuthorBuddy.Web
# ODER: scp -r . ubuntu@deine-vps:/opt/AuthorBuddy.Web
cd /opt/AuthorBuddy.Web
# Restore & Build (Release)
dotnet restore
dotnet publish -c Release -o /opt/AuthorBuddy.Web/publish
# Daten-Verzeichnis für Projekte anlegen
mkdir -p /home/ubuntu/Documents/AuthorBuddy
```
### Datenbankpfad prüfen
LiteDB erzeugt die Datenbank `author_brain.db` standardmäßig im `bin/`-Verzeichnis.
Besser: Ein festes Verzeichnis setzen → Umgebungsvariable oder Code-Anpassung in `Program.cs`:
```csharp
// Program.cs (optional falls gewünscht)
var dbDir = "/opt/AuthorBuddy.Web/data";
Directory.CreateDirectory(dbDir);
var dbPath = Path.Combine(dbDir, "author_brain.db");
```
---
## 5. Systemd-Service einrichten
```bash
sudo nano /etc/systemd/system/authorbuddy.service
```
```ini
[Unit]
Description=AuthorBuddy Web
After=network.target ollama.service
[Service]
WorkingDirectory=/opt/AuthorBuddy.Web/publish
ExecStart=/usr/share/dotnet/dotnet /opt/AuthorBuddy.Web/publish/AuthorBuddy.Web.dll
Restart=always
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=authorbuddy
User=ubuntu
Environment=ASPNETCORE_URLS=http://localhost:5000
Environment=ASPNETCORE_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable authorbuddy
sudo systemctl start authorbuddy
# Status prüfen
sudo systemctl status authorbuddy
# Logs live
sudo journalctl -u authorbuddy -f
```
---
## 6. Reverse Proxy mit Nginx
```bash
sudo apt install -y nginx
```
`/etc/nginx/sites-available/authorbuddy`:
```nginx
server {
listen 80;
server_name deine-domain.de;
# (Optional: später mit Certbot auf HTTPS upgraden)
# return 301 https://$host$request_uri;
location / {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Blazor Server benötigt lange Timeouts für Streaming
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
```
```bash
sudo ln -s /etc/nginx/sites-available/authorbuddy /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### HTTPS (Let's Encrypt)
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d deine-domain.de
```
---
## 7. Firewall
```bash
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
```
---
## 8. Einrichtung in der App
Nach dem ersten Start:
1. **Im Browser öffnen:** `http://deine-domain.de`
2. **Ersten Benutzer registrieren** (wird automatisch Admin)
3. **Settings → Ollama Server:** `http://localhost:11434`
4. **Test** Verbindung prüfen
5. **Modelle zuweisen:**
- RAG-Modell: `llama3.2:3b` (oder größer)
- Stil-Modell: `llama3.2:3b`
- Rechtschreib-Modell: `llama3.2:1b` (klein = schnell)
6. **Prompts prüfen** Standard-Prompts sind vorbelegt
---
## 9. Nützliche Befehle
| Aktion | Befehl |
|--------|--------|
| App neustarten | `sudo systemctl restart authorbuddy` |
| Logs ansehen | `sudo journalctl -u authorbuddy -f` |
| Ollama neustarten | `sudo systemctl restart ollama` |
| Verfügbare Modelle | `ollama list` |
| Modell laufend testen | `ollama run llama3.2:3b` |
| App stoppen | `sudo systemctl stop authorbuddy` |
---
## 10. Backup
```bash
# Datenbank + Projekte sichern
sudo tar czf /root/backup-authorbuddy-$(date +%Y%m%d).tar.gz \
/opt/AuthorBuddy.Web/data \
/home/ubuntu/Documents/AuthorBuddy
```
---
## Fehlerbehebung
- **502 Bad Gateway** → App-Logs prüfen: `journalctl -u authorbuddy -f`
- **Ollama nicht erreichbar**`curl http://localhost:11434` testen
- **Port bereits belegt**`ASPNETCORE_URLS` in der Service-Datei ändern
- **Blazor SignalR disconnect**`proxy_read_timeout` in Nginx erhöhen (86400s)

66
Models/FileCategory.cs Normal file
View file

@ -0,0 +1,66 @@
namespace AuthorBuddy.Web.Models;
[Flags]
public enum FileCategory
{
None = 0,
Character = 1,
Exposee = 2,
Fact = 4,
Document = 8
}
public static class FileCategoryHelper
{
public static bool IsValid(FileCategory value)
{
if (value == FileCategory.None) return true;
// Exposee and Document must be alone
bool hasExposee = value.HasFlag(FileCategory.Exposee);
bool hasDocument = value.HasFlag(FileCategory.Document);
bool hasCharacter = value.HasFlag(FileCategory.Character);
bool hasFact = value.HasFlag(FileCategory.Fact);
if (hasExposee && value != FileCategory.Exposee) return false;
if (hasDocument && value != FileCategory.Document) return false;
// Charakter + Fakt is the only valid multi-category combination
if (hasCharacter && hasFact && value == (FileCategory.Character | FileCategory.Fact)) return true;
// Single categories are valid
if (value is FileCategory.Character or FileCategory.Fact or FileCategory.Exposee or FileCategory.Document) return true;
return false;
}
public static string DisplayName(FileCategory category) => category switch
{
FileCategory.Character => "Charakter",
FileCategory.Exposee => "Exposé",
FileCategory.Fact => "Fakt",
FileCategory.Document => "Dokument",
FileCategory.Character | FileCategory.Fact => "Charakter + Fakt",
_ => ""
};
public static string DisplayIcon(FileCategory category) => category switch
{
FileCategory.Character => "👤",
FileCategory.Exposee => "📋",
FileCategory.Fact => "📌",
FileCategory.Document => "📄",
FileCategory.Character | FileCategory.Fact => "👤📌",
_ => ""
};
public static string CssClass(FileCategory category) => category switch
{
FileCategory.Character => "category-character",
FileCategory.Exposee => "category-exposee",
FileCategory.Fact => "category-fact",
FileCategory.Document => "category-document",
FileCategory.Character | FileCategory.Fact => "category-character-fact",
_ => ""
};
}

View file

@ -5,5 +5,6 @@ public class FileTreeItem
public string Name { get; set; } = string.Empty;
public string FullPath { get; set; } = string.Empty;
public bool IsDirectory { get; set; }
public FileCategory Category { get; set; }
public List<FileTreeItem> Children { get; set; } = new();
}

View file

@ -22,6 +22,11 @@ namespace AuthorBuddy.Web.Models
}
}
public LLM_Model(string name)
{
Name = name;
}
public string Name { get; set; } = string.Empty;
public long Size { get; set; }
public DateTime? ModifiedAt { get; set; }

View file

@ -6,6 +6,8 @@ public class OllamaSettings
public string EmbedModel { get; set; } = "nomic-embed-text";
public string SpellingModel { get; set; } = "phi3:mini";
public string OllamaUrl { get; set; } = "http://localhost:11434";
public string LlamaCppUrl { get; set; } = "http://localhost:8080";
public string Backend { get; set; } = "Ollama";
public string Theme { get; set; } = "light";
public string Language { get; set; } = "de";
@ -14,6 +16,20 @@ public class OllamaSettings
"Nutze starke Verben, vermeide unnötige Adjektive und achte auf 'Show, don't tell'. " +
"Antworte direkt mit dem verbesserten Text oder konkreten Vorschlägen.";
public string TechLastenheftPrompt { get; set; } = "Du bist ein erfahrener Requirements Engineer nach IREB-Standard. Erstelle ein Lastenheft aus den gegebenen Informationen. Gliedere es in: 1. Zielbestimmung, 2. Produkteinsatz, 3. Produktumgebung, 4. Funktionale Anforderungen, 5. Nicht-funktionale Anforderungen, 6. Lieferumfang. Formuliere alle Anforderungen eindeutig, nachvollziehbar und überprüfbar.";
public string TechPflichtenheftPrompt { get; set; } = "Du bist ein erfahrener Systemarchitekt. Erstelle aus dem Lastenheft ein detailliertes Pflichtenheft. Beschreibe: 1. Systemarchitektur, 2. Komponenten, 3. Schnittstellen, 4. Datenmodell, 5. Technische Umsetzung. Verwende präzise technische Sprache und vermeide Mehrdeutigkeiten.";
public string TechUseCasePrompt { get; set; } = "Du bist ein Experte für Use-Case-Modellierung nach UML-Standard. Definiere die wichtigsten Anwendungsfälle. Jeder Use Case enthält: 1. Akteure, 2. Vorbedingungen, 3. Nachbedingungen, 4. Normaler Ablauf (Schritt für Schritt), 5. Alternative Abläufe, 6. Ausnahmen.";
public string TechStakeholderPrompt { get; set; } = "Du bist ein erfahrener Stakeholder-Analyst. Identifiziere alle relevanten Stakeholder für das Projekt. Für jeden Stakeholder beschreibe: 1. Rolle und Interesse, 2. Erwartungen und Ziele, 3. Einfluss und Priorität, 4. Kommunikationsbedarf. Berücksichtige auch indirekte und versteckte Stakeholder.";
public string TechGlossarPrompt { get; set; } = "Du bist ein Terminologie-Manager. Erstelle ein fachliches Glossar mit klaren Definitionen aller wichtigen Begriffe, Abkürzungen und Akronyme aus dem Projektkontext. Strukturiere alphabetisch. Jeder Eintrag enthält: Begriff, Definition, Synonyme, Kontext.";
public string TechQualitaetPrompt { get; set; } = "Du bist ein Qualitätsmanager nach ISO 25010. Definiere die Qualitätsanforderungen in folgende Kategorien: 1. Funktionale Eignung, 2. Zuverlässigkeit, 3. Benutzbarkeit, 4. Effizienz, 5. Wartbarkeit, 6. Portabilität. Formuliere messbare Kriterien.";
public string TechRisikoPrompt { get; set; } = "Du bist ein Risikomanager. Identifiziere technische und fachliche Risiken des Projekts. Für jedes Risiko beschreibe: 1. Beschreibung, 2. Eintrittswahrscheinlichkeit (niedrig/mittel/hoch), 3. Auswirkung (niedrig/mittel/hoch), 4. Risikobewertung, 5. Maßnahmen zur Risikominimierung.";
public string FactCheckTemplate { get; set; } = "FAKTEN AUS DER DATENBANK:\n{0}\n\n" +
"FRAGE / KONTEXT:\n{1}\n\n" +
"Basierend auf den Fakten, schreibe eine kurze, lebendige Ergänzung oder Korrektur.";
@ -27,6 +43,10 @@ public class OllamaSettings
public string Eli5Prompt { get; set; } = "Wo ist der Text zu kompliziert geschrieben? Liefere mir konkrete Vorschläge, wie ich komplexe Absätze einfacher formulieren kann, ohne den Kern zu verlieren.";
public string CoAuthorBrainstormingPrompt { get; set; } = "Du bist ein kreativer Co-Autor. Der Nutzer gibt dir eine Idee, einen Charakter oder eine Situation. " +
"Hilf ihm, die Handlung oder Personen weiterzuentwickeln. Stelle kluge Fragen, schlage alternative Wendungen vor, " +
"denke über Motivationen, Konflikte und emotionale Tiefe nach. Sei inspirierend, aber bleib beim Thema.";
public string LektorSystemPromptEn { get; } = "You are an experienced editor. Analyze the author's text. " +
"Optimize it for a vivid, descriptive, and subjective style. " +
"Use strong verbs, avoid unnecessary adjectives, and follow 'Show, don't tell'. " +
@ -45,6 +65,40 @@ public class OllamaSettings
public string Eli5PromptEn { get; } = "Where is the text too complicated? Give me concrete suggestions on how to simplify complex paragraphs without losing the core message.";
public string CoAuthorBrainstormingPromptEn { get; } = "You are a creative co-author. The user gives you an idea, a character, or a situation. " +
"Help them develop the plot or characters further. Ask smart questions, suggest alternative twists, " +
"think about motivations, conflicts, and emotional depth. Be inspiring but stay on topic.";
public string TechLastenheftPromptEn { get; } = "You are an experienced Requirements Engineer following IREB standards. Create a requirements specification from the given information. Structure it into: 1. Objectives, 2. Product application, 3. Product environment, 4. Functional requirements, 5. Non-functional requirements, 6. Deliverables. Formulate all requirements clearly, traceably, and verifiably.";
public string TechPflichtenheftPromptEn { get; } = "You are an experienced system architect. Create a detailed functional specification from the requirements document. Describe: 1. System architecture, 2. Components, 3. Interfaces, 4. Data model, 5. Technical implementation. Use precise technical language and avoid ambiguity.";
public string TechUseCasePromptEn { get; } = "You are a UML use case modeling expert. Define the most important use cases. Each use case includes: 1. Actors, 2. Preconditions, 3. Postconditions, 4. Normal flow (step by step), 5. Alternative flows, 6. Exceptions.";
public string TechStakeholderPromptEn { get; } = "You are an experienced stakeholder analyst. Identify all relevant stakeholders for the project. For each stakeholder describe: 1. Role and interest, 2. Expectations and goals, 3. Influence and priority, 4. Communication needs. Also consider indirect and hidden stakeholders.";
public string TechGlossarPromptEn { get; } = "You are a terminology manager. Create a technical glossary with clear definitions of all important terms, abbreviations, and acronyms from the project context. Structure alphabetically. Each entry contains: term, definition, synonyms, context.";
public string TechQualitaetPromptEn { get; } = "You are a quality manager following ISO 25010. Define the quality requirements in the following categories: 1. Functional suitability, 2. Reliability, 3. Usability, 4. Efficiency, 5. Maintainability, 6. Portability. Formulate measurable criteria.";
public string TechRisikoPromptEn { get; } = "You are a risk manager. Identify technical and business risks for the project. For each risk describe: 1. Description, 2. Probability (low/medium/high), 3. Impact (low/medium/high), 4. Risk assessment, 5. Risk mitigation measures.";
public double LektorTemp { get; set; } = 0.7;
public double FactCheckTemp { get; set; } = 0.3;
public double SpellingTemp { get; set; } = 0.1;
public double AdvocatusDiaboliTemp { get; set; } = 0.9;
public double HookFinderTemp { get; set; } = 0.9;
public double Eli5Temp { get; set; } = 0.5;
public double CoAuthorTemp { get; set; } = 1.0;
public double TechLastenheftTemp { get; set; } = 0.3;
public double TechPflichtenheftTemp { get; set; } = 0.3;
public double TechUseCaseTemp { get; set; } = 0.4;
public double TechStakeholderTemp { get; set; } = 0.5;
public double TechGlossarTemp { get; set; } = 0.2;
public double TechQualitaetTemp { get; set; } = 0.3;
public double TechRisikoTemp { get; set; } = 0.5;
public string GetDefaultPrompt(string dePrompt, string enPrompt, string currentLanguage)
{
return currentLanguage == "en" ? enPrompt : dePrompt;
@ -52,6 +106,6 @@ public class OllamaSettings
public string GetFactCheckPrompt(string question, string facts)
{
return string.Format(FactCheckTemplate, question, facts);
return string.Format(FactCheckTemplate, facts, question);
}
}

View file

@ -5,4 +5,5 @@ public class Project
public int Id { get; internal set; }
public string Name { get; internal set; } = string.Empty;
public string RootPath { get; internal set; } = string.Empty;
public ProjectTemplate Template { get; internal set; }
}

View file

@ -0,0 +1,32 @@
namespace AuthorBuddy.Web.Models;
public static class ProjectPathHelper
{
public static string AllowedBasePath =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
"AuthorBuddy"
);
public static bool IsPathAllowed(string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
var full = Path.GetFullPath(path);
var allowed = Path.GetFullPath(AllowedBasePath);
return full.StartsWith(allowed + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| string.Equals(full, allowed, StringComparison.OrdinalIgnoreCase);
}
public static string SuggestPath(string projectName)
{
var safeName = Sanitize(projectName);
return Path.Combine(AllowedBasePath, safeName);
}
private static string Sanitize(string name)
{
var invalid = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalid.Contains(c)).ToArray());
return string.IsNullOrWhiteSpace(result) ? "Projekt" : result.Trim();
}
}

View file

@ -0,0 +1,7 @@
namespace AuthorBuddy.Web.Models;
public enum ProjectTemplate
{
Novel = 0,
Technical = 1
}

View file

@ -11,17 +11,27 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var dbPath = Path.Combine(AppContext.BaseDirectory, Constants.DbFile);
var dbDir = OperatingSystem.IsLinux()
? "/opt/AuthorBuddy.Web/data"
: AppContext.BaseDirectory;
Directory.CreateDirectory(dbDir);
var dbPath = Path.Combine(dbDir, Constants.DbFile);
builder.Services.AddSingleton<ILiteDatabase>(new LiteDatabase($"Filename={dbPath};Connection=direct"));
builder.Services.AddSingleton<OllamaSettings>();
builder.Services.AddSingleton<IOllamaService, OllamaService>();
builder.Services.AddSingleton<ILLMBackend, OllamaBackend>();
builder.Services.AddSingleton<ILLMBackend, LlamaCppBackend>();
builder.Services.AddSingleton<IOllamaSetupService, OllamaSetupService>();
builder.Services.AddSingleton<IFileScannerService, FileScannerService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IThemeService, ThemeService>();
builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddSingleton<IExportService, ExportService>();
builder.Services.AddSingleton<IRagService, RagService>();
builder.Services.AddSingleton<IWikipediaService, WikipediaService>();
builder.Services.AddSingleton<OperationState>();
builder.Services.AddSingleton<IDbAdminService, DbAdminService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ILocalizationService, LocalizationService>();
builder.Services.AddScoped<CustomAuthStateProvider>();

View file

@ -14,6 +14,8 @@
"page.title.home": "Dashboard - Author Buddy",
"page.title.error": "Fehler",
"page.title.not_found": "Seite nicht gefunden",
"page.title.help": "Hilfe",
"nav.menu.extras.help": "Hilfe",
"nav.menu.file": "Datei",
"nav.menu.file.new_project": "Neues Projekt",
"nav.menu.file.open_editor": "Editor öffnen",
@ -107,6 +109,11 @@
"settings.section.ollama_server": "Ollama Server",
"settings.ollama.label.url": "Server-URL:",
"settings.ollama.hint.url": "Adresse des Ollama-Servers",
"settings.ollama.test": "Test",
"settings.ollama.online": "Online",
"settings.ollama.offline": "Keine Verbindung",
"settings.section.diagnostics": "Ollama Diagnose",
"settings.diagnostics.run": "Diagnose ausführen",
"settings.section.appearance": "Design",
"settings.appearance.label.theme": "Theme:",
"settings.appearance.theme.light": "Hell",
@ -120,6 +127,14 @@
"settings.section.advocatus_diaboli": "Advocatus Diaboli",
"settings.section.hook_finder": "Hook-Finder",
"settings.section.eli5": "ELI5-Vereinfachung",
"settings.section.coauthor_brainstorming": "Co-Author Brainstorming",
"settings.section.tech_lastenheft": "📋 Lastenheft",
"settings.section.tech_pflichtenheft": "⚙️ Pflichtenheft",
"settings.section.tech_usecase": "🔄 Use Cases",
"settings.section.tech_stakeholder": "👥 Stakeholder-Analyse",
"settings.section.tech_glossar": "📖 Glossar",
"settings.section.tech_qualitaet": "✅ Qualitätsanforderungen",
"settings.section.tech_risiko": "⚠️ Risikoanalyse",
"settings.button.save": "Speichern",
"settings.status.user_created": "Benutzer '{0}' erstellt.",
"settings.status.saved": "Einstellungen gespeichert.",
@ -129,13 +144,31 @@
"assistant.mode.eli5": "ELI5-Vereinfachung",
"assistant.button.execute": "Ausführen",
"assistant.button.send_to_editor": "An Editor übermitteln",
"assistant.button.edit_prompt": "Prompt bearbeiten",
"assistant.button.hide_prompt": "Prompt ausblenden",
"assistant.button.reset_prompt": "Zurücksetzen",
"assistant.label.system_prompt": "System-Prompt",
"assistant.status.starting": "Starte Abfrage...",
"assistant.status.searching": "Suche Fakten...",
"assistant.label.input_text": "Eingabetext",
"assistant.placeholder.input": "Füge hier deinen Text ein...",
"assistant.label.result": "Ergebnis",
"assistant.hint.empty_result": "Ergebnis erscheint hier nach der Ausführung.",
"assistant.status.processing": "Verarbeite...",
"assistant.mode.coauthor": "Co-Author Brainstorming",
"assistant.mode.rag": "RAG-Faktenabfrage",
"assistant.mode.tech_lastenheft": "📋 Lastenheft",
"assistant.mode.tech_pflichtenheft": "⚙️ Pflichtenheft",
"assistant.mode.tech_usecase": "🔄 Use Cases",
"assistant.mode.tech_stakeholder": "👥 Stakeholder-Analyse",
"assistant.mode.tech_glossar": "📖 Glossar",
"assistant.mode.tech_qualitaet": "✅ Qualitätsanforderungen",
"assistant.mode.tech_risiko": "⚠️ Risikoanalyse",
"assistant.error.no_prompt": "Kein System-Prompt für diesen Modus konfiguriert. Bitte in den Einstellungen hinterlegen.",
"assistant.error.no_project": "Kein Projekt geladen. Bitte zuerst ein Projekt im Editor öffnen.",
"assistant.error.no_facts": "Keine relevanten Fakten im Projektindex gefunden. Bitte zuerst das Projekt indexieren.",
"assistant.status.done": "Fertig.",
"assistant.exposee.context": "Berücksichtige folgendes Exposé der Geschichte:",
"models.heading": "Ollama Modelle verwalten",
"models.button.refresh": "Aktualisieren",
"models.status.loading": "Lade Modelle...",
@ -188,6 +221,16 @@
"editor.button.create": "Erstellen",
"editor.status.imported_from_assistant": "Text aus Assistent übernommen.",
"editor.status.loaded": "Geladen:",
"editor.warning.context": "Datei ist sehr lang Kontextfenster könnte überschritten werden",
"settings.temperature.label": "Kreativität (Temperatur):",
"settings.temperature.category.precise": "Der Buchhalter (Präzise & Streng)",
"settings.temperature.category.balanced": "Der Lektor (Ausgewogen)",
"settings.temperature.category.creative": "Der Co-Autor (Kreativ)",
"settings.temperature.category.chaos": "Der Betrunkene (Chaos)",
"settings.temperature.desc.precise": "Deterministisch, wiederholbar, logisch, trocken",
"settings.temperature.desc.balanced": "Natürlicher Satzbau, bleibt faktennah",
"settings.temperature.desc.creative": "Unerwartete Metaphern, Wendungen, kreative Ideen",
"settings.temperature.desc.chaos": "Unbrauchbar für produktives Schreiben",
"editor.status.analyzing_style": "Ollama analysiert Stil...",
"editor.status.checking_spelling": "Ollama prüft Rechtschreibung...",
"editor.status.searching_rag": "Suche verwandte Fakten...",
@ -237,5 +280,14 @@
"usermanagement.status.activated": "Benutzer freigeschaltet.",
"usermanagement.status.deactivated": "Benutzer deaktiviert.",
"usermanagement.status.deleted": "Benutzer gelöscht.",
"common.back": "Zurück"
"common.back": "Zurück",
"export.title": "Dokumente exportieren",
"export.file_count": "Ausgewählte Dateien",
"export.label.filename": "Dateiname",
"export.placeholder.filename": "z.B. MeinExport",
"export.hint.filename": "Die Datei wird im Browser-Download-Ordner gespeichert.",
"export.label.format": "Format",
"export.format.markdown": "Markdown (Einzeldatei)",
"export.format.zip": "Markdown (ZIP-Archiv)",
"export.button.export": "Exportieren"
}

View file

@ -14,6 +14,8 @@
"page.title.home": "Dashboard - Author Buddy",
"page.title.error": "Error",
"page.title.not_found": "Page not found",
"page.title.help": "Help",
"nav.menu.extras.help": "Help",
"nav.menu.file": "File",
"nav.menu.file.new_project": "New project",
"nav.menu.file.open_editor": "Open editor",
@ -107,6 +109,11 @@
"settings.section.ollama_server": "Ollama Server",
"settings.ollama.label.url": "Server URL:",
"settings.ollama.hint.url": "Ollama server address",
"settings.ollama.test": "Test",
"settings.ollama.online": "Online",
"settings.ollama.offline": "No connection",
"settings.section.diagnostics": "Ollama Diagnostics",
"settings.diagnostics.run": "Run diagnostics",
"settings.section.appearance": "Appearance",
"settings.appearance.label.theme": "Theme:",
"settings.appearance.theme.light": "Light",
@ -120,6 +127,14 @@
"settings.section.advocatus_diaboli": "Advocatus Diaboli",
"settings.section.hook_finder": "Hook finder",
"settings.section.eli5": "ELI5-Simplification",
"settings.section.coauthor_brainstorming": "Co-Author Brainstorming",
"settings.section.tech_lastenheft": "📋 Requirements Spec",
"settings.section.tech_pflichtenheft": "⚙️ Functional Spec",
"settings.section.tech_usecase": "🔄 Use Cases",
"settings.section.tech_stakeholder": "👥 Stakeholder Analysis",
"settings.section.tech_glossar": "📖 Glossary",
"settings.section.tech_qualitaet": "✅ Quality Requirements",
"settings.section.tech_risiko": "⚠️ Risk Analysis",
"settings.button.save": "Save",
"settings.status.user_created": "User '{0}' created.",
"settings.status.saved": "Settings saved.",
@ -129,13 +144,31 @@
"assistant.mode.eli5": "ELI5 simplification",
"assistant.button.execute": "Execute",
"assistant.button.send_to_editor": "Send to editor",
"assistant.button.edit_prompt": "Edit prompt",
"assistant.button.hide_prompt": "Hide prompt",
"assistant.button.reset_prompt": "Reset",
"assistant.label.system_prompt": "System prompt",
"assistant.status.starting": "Starting...",
"assistant.status.searching": "Searching facts...",
"assistant.label.input_text": "Input text",
"assistant.placeholder.input": "Paste your text here...",
"assistant.label.result": "Result",
"assistant.hint.empty_result": "Result appears here after execution.",
"assistant.status.processing": "Processing...",
"assistant.mode.coauthor": "Co-Author Brainstorming",
"assistant.mode.rag": "RAG fact query",
"assistant.mode.tech_lastenheft": "📋 Requirements Spec",
"assistant.mode.tech_pflichtenheft": "⚙️ Functional Spec",
"assistant.mode.tech_usecase": "🔄 Use Cases",
"assistant.mode.tech_stakeholder": "👥 Stakeholder Analysis",
"assistant.mode.tech_glossar": "📖 Glossary",
"assistant.mode.tech_qualitaet": "✅ Quality Requirements",
"assistant.mode.tech_risiko": "⚠️ Risk Analysis",
"assistant.error.no_prompt": "No system prompt configured for this mode. Please configure in settings.",
"assistant.error.no_project": "No project loaded. Please open a project in the editor first.",
"assistant.error.no_facts": "No relevant facts found in the project index. Please index the project first.",
"assistant.status.done": "Done.",
"assistant.exposee.context": "Consider the following story outline (Exposé):",
"models.heading": "Manage Ollama models",
"models.button.refresh": "Refresh",
"models.status.loading": "Loading models...",
@ -188,6 +221,16 @@
"editor.button.create": "Create",
"editor.status.imported_from_assistant": "Text imported from assistant.",
"editor.status.loaded": "Loaded:",
"editor.warning.context": "File is very long context window may be exceeded",
"settings.temperature.label": "Creativity (Temperature):",
"settings.temperature.category.precise": "The Accountant (Precise & Strict)",
"settings.temperature.category.balanced": "The Editor (Balanced)",
"settings.temperature.category.creative": "The Co-Author (Creative)",
"settings.temperature.category.chaos": "The Drunk (Chaos)",
"settings.temperature.desc.precise": "Deterministic, repeatable, logical, dry",
"settings.temperature.desc.balanced": "Natural phrasing, stays close to facts",
"settings.temperature.desc.creative": "Unexpected metaphors, twists, creative ideas",
"settings.temperature.desc.chaos": "Unusable for productive writing",
"editor.status.analyzing_style": "Ollama analyzing style...",
"editor.status.checking_spelling": "Ollama checking spelling...",
"editor.status.searching_rag": "Searching related facts...",
@ -237,5 +280,14 @@
"usermanagement.status.activated": "User activated.",
"usermanagement.status.deactivated": "User deactivated.",
"usermanagement.status.deleted": "User deleted.",
"common.back": "Back"
"common.back": "Back",
"export.title": "Export documents",
"export.file_count": "Selected files",
"export.label.filename": "Filename",
"export.placeholder.filename": "e.g. MyExport",
"export.hint.filename": "The file will be saved in the browser download folder.",
"export.label.format": "Format",
"export.format.markdown": "Markdown (single file)",
"export.format.zip": "Markdown (ZIP archive)",
"export.button.export": "Export"
}

111
Services/DbAdminService.cs Normal file
View file

@ -0,0 +1,111 @@
using LiteDB;
namespace AuthorBuddy.Web.Services;
public class DbAdminService : IDbAdminService
{
private readonly ILiteDatabase _db;
private static readonly string[] KnownCollections =
{
"users", "projects", "settings", "fileCategories", "documentMeta", "vectors"
};
public DbAdminService(ILiteDatabase db)
{
_db = db;
}
public List<DbCollectionInfo> GetCollections()
{
var result = new List<DbCollectionInfo>();
foreach (var name in KnownCollections)
{
var col = _db.GetCollection(name);
result.Add(new DbCollectionInfo
{
Name = name,
Count = col.Count()
});
}
return result;
}
public List<DbRecord> GetAll(string collectionName)
{
var col = _db.GetCollection(collectionName);
var docs = col.FindAll().ToList();
var result = new List<DbRecord>();
foreach (var doc in docs)
{
var id = doc["_id"].AsInt32;
var fields = new Dictionary<string, string?>();
foreach (var key in doc.Keys)
{
if (key == "_id") continue;
fields[key] = BsonToDisplay(doc[key]);
}
result.Add(new DbRecord { Id = id, Fields = fields });
}
return result;
}
public bool Update(string collectionName, int id, Dictionary<string, string?> values)
{
try
{
var col = _db.GetCollection(collectionName);
var doc = col.FindById(new BsonValue(id));
if (doc == null) return false;
foreach (var kv in values)
{
if (kv.Key == "_id") continue;
doc[kv.Key] = ParseBsonValue(kv.Value);
}
return col.Update(doc);
}
catch
{
return false;
}
}
public bool Delete(string collectionName, int id)
{
try
{
var col = _db.GetCollection(collectionName);
return col.Delete(new BsonValue(id));
}
catch
{
return false;
}
}
private static string? BsonToDisplay(BsonValue val)
{
if (val.IsNull) return null;
if (val.IsDateTime) return val.AsDateTime.ToString("yyyy-MM-dd HH:mm:ss");
if (val.IsBoolean) return val.AsBoolean.ToString();
if (val.IsDouble) return val.AsDouble.ToString("G");
if (val.IsInt32) return val.AsInt32.ToString();
if (val.IsInt64) return val.AsInt64.ToString();
if (val.IsString) return val.AsString;
if (val.IsArray) return $"[Array ({val.AsArray.Count} items)]";
if (val.IsDocument) return $"[Object]";
return val.RawValue?.ToString();
}
private static BsonValue ParseBsonValue(string? value)
{
if (value == null) return BsonValue.Null;
if (int.TryParse(value, out var i)) return new BsonValue(i);
if (long.TryParse(value, out var l)) return new BsonValue(l);
if (double.TryParse(value, out var d)) return new BsonValue(d);
if (bool.TryParse(value, out var b)) return new BsonValue(b);
return new BsonValue(value);
}
}

425
Services/ExportService.cs Normal file
View file

@ -0,0 +1,425 @@
using System.IO.Compression;
using System.Text;
using System.Text.RegularExpressions;
using AuthorBuddy.Web.Models;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace AuthorBuddy.Web.Services;
public partial class ExportService : IExportService
{
private readonly IProjectService _projectService;
public ExportService(IProjectService projectService)
{
_projectService = projectService;
QuestPDF.Settings.License = LicenseType.Community;
}
public async Task<byte[]> ExportAsync(int projectId, List<string> filePaths, ExportFormat format)
{
var project = await _projectService.GetProjectAsync(projectId);
var projectName = project?.Name ?? "Projekt";
var files = new List<(string name, string content)>();
foreach (var path in filePaths)
{
if (File.Exists(path))
{
var content = await File.ReadAllTextAsync(path, Encoding.UTF8);
var name = Path.GetFileName(path);
files.Add((name, content));
}
}
return format switch
{
ExportFormat.Html => GenerateHtml(projectName, files),
ExportFormat.Markdown => GenerateMarkdown(projectName, files),
ExportFormat.Zip => GenerateZip(files),
ExportFormat.Docx => GenerateDocx(projectName, files),
ExportFormat.Pdf => GeneratePdf(projectName, files),
_ => throw new ArgumentOutOfRangeException(nameof(format))
};
}
private static byte[] GenerateHtml(string projectName, List<(string name, string content)> files)
{
var sb = new StringBuilder();
sb.AppendLine("<!DOCTYPE html>");
sb.AppendLine("<html lang=\"de\">");
sb.AppendLine("<head>");
sb.AppendLine("<meta charset=\"utf-8\">");
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
sb.AppendLine($"<title>{EscapeHtml(projectName)} Export</title>");
sb.AppendLine("<style>");
sb.AppendLine(GetHtmlCss());
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.AppendLine($"<header class=\"doc-header\"><h1>{EscapeHtml(projectName)}</h1><p class=\"doc-date\">Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}</p></header>");
foreach (var (name, content) in files)
{
sb.AppendLine($"<section class=\"doc-section\"><h2>{EscapeHtml(name)}</h2><div class=\"doc-content\">{RenderMarkdownAsHtml(content)}</div></section>");
}
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] GenerateMarkdown(string projectName, List<(string name, string content)> files)
{
var sb = new StringBuilder();
sb.AppendLine($"# {projectName}");
sb.AppendLine();
sb.AppendLine($"> Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}");
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
foreach (var (name, content) in files)
{
sb.AppendLine($"## {name}");
sb.AppendLine();
sb.AppendLine(content.TrimEnd());
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine();
}
return Encoding.UTF8.GetBytes(sb.ToString());
}
private static byte[] GenerateZip(List<(string name, string content)> files)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var (name, content) in files)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
using var writer = new StreamWriter(entry.Open(), Encoding.UTF8);
writer.Write(content);
}
}
return ms.ToArray();
}
private static byte[] GenerateDocx(string projectName, List<(string name, string content)> files)
{
using var ms = new MemoryStream();
using var doc = WordprocessingDocument.Create(ms, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new DocumentFormat.OpenXml.Wordprocessing.Document();
var body = mainPart.Document.AppendChild(new Body());
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold()), new Text(projectName)))
{ ParagraphProperties = new ParagraphProperties(new Justification { Val = JustificationValues.Center }) });
body.AppendChild(new Paragraph(
new Run(new RunProperties(new DocumentFormat.OpenXml.Wordprocessing.Color { Val = "666666" }, new FontSize { Val = "20" }),
new Text($"Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}"))));
foreach (var (name, content) in files)
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "32" }), new Text(name))
));
var lines = content.Split('\n');
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line))
{
body.AppendChild(new Paragraph());
continue;
}
if (line.StartsWith("### "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "28" }), new Text(line[4..]))
));
}
else if (line.StartsWith("## "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "32" }), new Text(line[3..]))
));
}
else if (line.StartsWith("# "))
{
body.AppendChild(new Paragraph(
new Run(new RunProperties(new Bold(), new FontSize { Val = "36" }), new Text(line[2..]))
));
}
else if (line.StartsWith("- ") || line.StartsWith("* "))
{
body.AppendChild(new Paragraph(
new Run(new Text($" • {line[2..]}"))
));
}
else if (line.Length > 2 && char.IsDigit(line[0]) && line[1] == '.' && line[2] == ' ')
{
body.AppendChild(new Paragraph(
new Run(new Text($" {line}"))
));
}
else
{
body.AppendChild(new Paragraph(
new Run(new Text(line))
));
}
}
}
doc.Save();
return ms.ToArray();
}
private static byte[] GeneratePdf(string projectName, List<(string name, string content)> files)
{
var doc = QuestPDF.Fluent.Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(40);
page.DefaultTextStyle(x => x.FontSize(11).FontFamily("Times New Roman"));
page.Header().Column(col =>
{
col.Item().AlignCenter().Text(projectName).Bold().FontSize(22);
col.Item().AlignCenter().Text($"Exportiert: {DateTime.Now:dd.MM.yyyy HH:mm}").FontSize(10).FontColor(Colors.Grey.Medium);
});
page.Content().Column(col =>
{
foreach (var (name, content) in files)
{
col.Item().PaddingTop(12).Text(name).Bold().FontSize(16);
col.Item().PaddingLeft(4).Element(c => RenderMarkdownToPdf(c, content));
}
});
page.Footer().AlignCenter().Text(x =>
{
x.Span("Seite ").FontSize(9);
x.CurrentPageNumber().FontSize(9);
});
});
});
using var ms = new MemoryStream();
doc.GeneratePdf(ms);
return ms.ToArray();
}
private static void RenderMarkdownToPdf(IContainer container, string md)
{
var lines = md.Split('\n');
container.Column(col =>
{
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line))
{
col.Item().Height(8);
continue;
}
if (line.StartsWith("### "))
{
col.Item().PaddingTop(6).Text(line[4..]).Bold().FontSize(13);
}
else if (line.StartsWith("## "))
{
col.Item().PaddingTop(8).Text(line[3..]).Bold().FontSize(15);
}
else if (line.StartsWith("# "))
{
col.Item().PaddingTop(10).Text(line[2..]).Bold().FontSize(17);
}
else if (line.StartsWith("- ") || line.StartsWith("* "))
{
col.Item().PaddingLeft(12).Text($" • {line[2..]}").FontSize(11);
}
else if (line is "---" or "***" or "___")
{
col.Item().Height(1).Background(Colors.Grey.Lighten2);
}
else if (line.Length > 2 && char.IsDigit(line[0]) && line[1] == '.' && line[2] == ' ')
{
col.Item().PaddingLeft(12).Text($" {line}").FontSize(11);
}
else
{
col.Item().Text(line).FontSize(11);
}
}
});
}
private static string EscapeHtml(string text)
=> text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;").Replace("\"", "&quot;");
private static string RenderMarkdownAsHtml(string md)
{
var lines = md.Split('\n');
var sb = new StringBuilder();
var inList = false;
foreach (var raw in lines)
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("###### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h6>{EscapeHtml(line[7..])}</h6>");
continue;
}
if (line.StartsWith("##### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h5>{EscapeHtml(line[6..])}</h5>");
continue;
}
if (line.StartsWith("#### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h4>{EscapeHtml(line[5..])}</h4>");
continue;
}
if (line.StartsWith("### "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h3>{EscapeHtml(line[4..])}</h3>");
continue;
}
if (line.StartsWith("## "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h2>{EscapeHtml(line[3..])}</h2>");
continue;
}
if (line.StartsWith("# "))
{
CloseList(ref inList, sb);
sb.AppendLine($"<h1>{EscapeHtml(line[2..])}</h1>");
continue;
}
if (line.StartsWith("- ") || line.StartsWith("* "))
{
if (!inList)
{
inList = true;
sb.AppendLine("<ul>");
}
sb.AppendLine($"<li>{RenderInline(line[2..])}</li>");
continue;
}
if (line.Length > 2 && char.IsDigit(line[0]) && (line[1] == '.' || line[1] == ')') && line[2] == ' ')
{
if (!inList)
{
inList = true;
sb.AppendLine("<ol>");
}
sb.AppendLine($"<li>{RenderInline(line[3..])}</li>");
continue;
}
if (line is "---" or "***" or "___")
{
CloseList(ref inList, sb);
sb.AppendLine("<hr>");
continue;
}
if (string.IsNullOrWhiteSpace(line))
{
CloseList(ref inList, sb);
continue;
}
CloseList(ref inList, sb);
sb.AppendLine($"<p>{RenderInline(line)}</p>");
}
CloseList(ref inList, sb);
return sb.ToString();
}
private static void CloseList(ref bool inList, StringBuilder sb)
{
if (inList)
{
sb.AppendLine(sb[sb.Length - 1] == '>' ? "</ol>" : "</ul>");
inList = false;
}
}
private static string RenderInline(string text)
{
var result = EscapeHtml(text);
result = BoldRegex().Replace(result, "<strong>$1</strong>");
result = ItalicRegex().Replace(result, "<em>$1</em>");
result = UnderlineRegex().Replace(result, "<u>$1</u>");
result = CodeRegex().Replace(result, "<code>$1</code>");
result = LinkRegex().Replace(result, "<a href=\"$2\">$1</a>");
return result;
}
private static string GetHtmlCss()
{
return """
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: Georgia, 'Times New Roman', serif; font-size: 12pt; line-height: 1.6; color: #222; max-width: 210mm; margin: 0 auto; padding: 20mm 30mm; background: #fff; }
.doc-header { text-align: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #333; }
.doc-header h1 { font-size: 22pt; margin-bottom: 4px; }
.doc-date { font-size: 10pt; color: #666; }
.doc-section { margin-bottom: 25px; page-break-before: auto; }
.doc-section h2 { font-size: 16pt; margin-bottom: 10px; padding-bottom: 3px; border-bottom: 1px solid #ccc; color: #333; }
.doc-content h1 { font-size: 18pt; margin: 15px 0 8px; }
.doc-content h2 { font-size: 15pt; margin: 12px 0 6px; }
.doc-content h3 { font-size: 13pt; margin: 10px 0 5px; }
.doc-content h4 { font-size: 12pt; margin: 8px 0 4px; }
.doc-content h5 { font-size: 11pt; margin: 6px 0 3px; }
.doc-content h6 { font-size: 10pt; margin: 6px 0 3px; color: #555; }
.doc-content p { margin: 6px 0; text-align: justify; }
.doc-content ul, .doc-content ol { margin: 6px 0 6px 20px; }
.doc-content li { margin: 2px 0; }
.doc-content hr { margin: 15px 0; border: none; border-top: 1px solid #ccc; }
.doc-content code { font-family: 'Consolas', 'Courier New', monospace; font-size: 10pt; background: #f4f4f4; padding: 1px 4px; border-radius: 2px; }
.doc-content a { color: #1a73e8; text-decoration: underline; }
.doc-content strong { font-weight: bold; }
.doc-content em { font-style: italic; }
@media print { body { padding: 0; } .doc-section { page-break-before: always; } .doc-section:first-of-type { page-break-before: auto; } }
""";
}
[GeneratedRegex(@"\*\*(.+?)\*\*")]
private static partial Regex BoldRegex();
[GeneratedRegex(@"\*(.+?)\*")]
private static partial Regex ItalicRegex();
[GeneratedRegex(@"<u>(.+?)</u>")]
private static partial Regex UnderlineRegex();
[GeneratedRegex(@"`(.+?)`")]
private static partial Regex CodeRegex();
[GeneratedRegex(@"\[([^\]]+)\]\(([^)]+)\)")]
private static partial Regex LinkRegex();
}

View file

@ -0,0 +1,21 @@
namespace AuthorBuddy.Web.Services;
public class DbCollectionInfo
{
public string Name { get; set; } = string.Empty;
public int Count { get; set; }
}
public class DbRecord
{
public int Id { get; set; }
public Dictionary<string, string?> Fields { get; set; } = new();
}
public interface IDbAdminService
{
List<DbCollectionInfo> GetCollections();
List<DbRecord> GetAll(string collectionName);
bool Update(string collectionName, int id, Dictionary<string, string?> values);
bool Delete(string collectionName, int id);
}

View file

@ -0,0 +1,15 @@
namespace AuthorBuddy.Web.Services;
public enum ExportFormat
{
Html,
Markdown,
Zip,
Docx,
Pdf
}
public interface IExportService
{
Task<byte[]> ExportAsync(int projectId, List<string> filePaths, ExportFormat format);
}

22
Services/ILLMBackend.cs Normal file
View file

@ -0,0 +1,22 @@
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Services;
public interface ILLMBackend
{
string Name { get; }
bool SupportsModelManagement { get; }
Task<bool> TestConnectionAsync();
Task<IEnumerable<LLM_Model>> GetModelsAsync();
Task<string> ChatAsync(string model, string prompt, string? systemPrompt = null, double? temperature = null);
Task ChatStreamAsync(string model, string prompt, Func<string, Task> onToken, string? systemPrompt = null, double? temperature = null);
Task<float[]> GetEmbeddingAsync(string text, string? model = null);
Task<bool> PullModelAsync(string name, IProgress<string>? progress = null);
Task<bool> DeleteModelAsync(string name);
Task<int?> GetContextSizeAsync(string model);
Task<long> MeasureLatencyAsync(string model);
Task<bool> ModelExistsAsync(string model);
Task<(bool Success, int ContextWindow)> GetModelContextWindowAsync(string model);
bool TestEmbedding(string model);
}

View file

@ -11,6 +11,12 @@ public interface IOllamaService
Task<string> QuickSpellCheckAsync(string text);
Task<float[]> GetEmbeddingAsync(string text);
Task<bool> TestEmbeddingAsync(string model);
Task<string> Chat(string model, string userMessage, string? systemPrompt = null);
Task<string> Chat(string model, string userMessage, string? systemPrompt = null, double? temperature = null);
Task ChatStream(string model, string userMessage, Func<string, Task> onToken, string? systemPrompt = null, double? temperature = null);
Task<string> AnswerBasedOnFactsAsync(string question, string facts);
Task<(bool Success, long LatencyMs)> TestConnectionAsync();
Task<bool> ModelExistsAsync(string model);
Task<(bool Success, int ContextWindow)> GetModelContextWindowAsync(string model);
Task<long> MeasureLatencyAsync(string model);
void Reconfigure(string ollamaUrl);
}

View file

@ -0,0 +1,17 @@
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Services;
public interface IOllamaSetupService
{
Task<SetupRecommendation> AnalyzeSettingsAsync();
Task<bool> CreateConfigFileAsync(OllamaSettings settings);
}
public class SetupRecommendation
{
public bool IsCorrect { get; set; }
public string Message { get; set; } = string.Empty;
public OllamaSettings RecommendedSettings { get; set; } = new();
public List<string> Findings { get; set; } = new();
}

View file

@ -4,10 +4,14 @@ namespace AuthorBuddy.Web.Services;
public interface IProjectService
{
Task<int> CreateNewProjectAsync(string projectName, string rootPath);
Task<List<Project>> GetAllProjectsAsync();
Task<int> CreateNewProjectAsync(string projectName, string rootPath, int userId, ProjectTemplate template = ProjectTemplate.Novel);
Task<List<Project>> GetAllProjectsAsync(int? userId = null);
Task<Project?> GetProjectAsync(int projectId);
Task<List<FileTreeItem>> GetProjectFileTreeAsync(int projectId);
Task SyncProjectFilesAsync(int projectId, string directoryPath, IProgress<string> progress);
Task<int> CopyProjectAsync(int sourceProjectId, string newName, string newRootPath, int userId);
Task<Dictionary<string, FileCategory>> GetFileCategoriesAsync(int projectId);
Task<bool> SetFileCategoryAsync(int projectId, string filePath, FileCategory category);
Task ClearFileCategoryAsync(int projectId, string filePath);
}

View file

@ -5,11 +5,15 @@ namespace AuthorBuddy.Web.Services;
public interface ISettingsService
{
Task<string?> GetOllamaUrlAsync();
Task<string?> GetLlamaCppUrlAsync();
Task<string?> GetBackendAsync();
Task<string?> GetRagModelAsync();
Task<string?> GetStyleModelAsync();
Task<string?> GetSpellingModelAsync();
Task<string?> GetThemeAsync();
Task SaveOllamaUrlAsync(string url);
Task SaveLlamaCppUrlAsync(string url);
Task SaveBackendAsync(string backend);
Task SaveRagModelAsync(string model);
Task SaveStyleModelAsync(string model);
Task SaveSpellingModelAsync(string model);
@ -21,6 +25,7 @@ public interface ISettingsService
Task<string?> GetAdvocatusDiaboliPromptAsync();
Task<string?> GetHookFinderPromptAsync();
Task<string?> GetEli5PromptAsync();
Task<string?> GetCoAuthorBrainstormingPromptAsync();
Task SaveLektorSystemPromptAsync(string prompt);
Task SaveFactCheckTemplateAsync(string prompt);
@ -28,6 +33,50 @@ public interface ISettingsService
Task SaveAdvocatusDiaboliPromptAsync(string prompt);
Task SaveHookFinderPromptAsync(string prompt);
Task SaveEli5PromptAsync(string prompt);
Task SaveCoAuthorBrainstormingPromptAsync(string prompt);
Task<double> GetLektorTempAsync();
Task<double> GetFactCheckTempAsync();
Task<double> GetSpellingTempAsync();
Task<double> GetAdvocatusDiaboliTempAsync();
Task<double> GetHookFinderTempAsync();
Task<double> GetEli5TempAsync();
Task<double> GetCoAuthorTempAsync();
Task SaveLektorTempAsync(double temp);
Task SaveFactCheckTempAsync(double temp);
Task SaveSpellingTempAsync(double temp);
Task SaveAdvocatusDiaboliTempAsync(double temp);
Task SaveHookFinderTempAsync(double temp);
Task SaveEli5TempAsync(double temp);
Task SaveCoAuthorTempAsync(double temp);
Task<string?> GetTechLastenheftPromptAsync();
Task<string?> GetTechPflichtenheftPromptAsync();
Task<string?> GetTechUseCasePromptAsync();
Task<string?> GetTechStakeholderPromptAsync();
Task<string?> GetTechGlossarPromptAsync();
Task<string?> GetTechQualitaetPromptAsync();
Task<string?> GetTechRisikoPromptAsync();
Task SaveTechLastenheftPromptAsync(string prompt);
Task SaveTechPflichtenheftPromptAsync(string prompt);
Task SaveTechUseCasePromptAsync(string prompt);
Task SaveTechStakeholderPromptAsync(string prompt);
Task SaveTechGlossarPromptAsync(string prompt);
Task SaveTechQualitaetPromptAsync(string prompt);
Task SaveTechRisikoPromptAsync(string prompt);
Task<double> GetTechLastenheftTempAsync();
Task<double> GetTechPflichtenheftTempAsync();
Task<double> GetTechUseCaseTempAsync();
Task<double> GetTechStakeholderTempAsync();
Task<double> GetTechGlossarTempAsync();
Task<double> GetTechQualitaetTempAsync();
Task<double> GetTechRisikoTempAsync();
Task SaveTechLastenheftTempAsync(double temp);
Task SaveTechPflichtenheftTempAsync(double temp);
Task SaveTechUseCaseTempAsync(double temp);
Task SaveTechStakeholderTempAsync(double temp);
Task SaveTechGlossarTempAsync(double temp);
Task SaveTechQualitaetTempAsync(double temp);
Task SaveTechRisikoTempAsync(double temp);
Task<string?> GetLanguageAsync();
Task SaveLanguageAsync(string language);

236
Services/LlamaCppBackend.cs Normal file
View file

@ -0,0 +1,236 @@
using System.Text.Json;
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Services;
public class LlamaCppBackend : ILLMBackend
{
private readonly HttpClient _http;
private readonly OllamaSettings _settings;
public string Name => "llama.cpp";
public bool SupportsModelManagement => false;
public LlamaCppBackend(OllamaSettings settings)
{
_settings = settings;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
}
private string Url => _settings.LlamaCppUrl;
public async Task<bool> TestConnectionAsync()
{
try
{
var response = await _http.GetAsync($"{Url}/health");
return response.IsSuccessStatusCode;
}
catch { return false; }
}
public async Task<IEnumerable<LLM_Model>> GetModelsAsync()
{
try
{
var response = await _http.GetAsync($"{Url}/v1/models");
if (!response.IsSuccessStatusCode)
return new List<LLM_Model> { new LLM_Model("llama.cpp") };
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("data", out var data))
return new List<LLM_Model> { new LLM_Model("llama.cpp") };
var list = new List<LLM_Model>();
foreach (var m in data.EnumerateArray())
{
var id = m.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "llama.cpp" : "llama.cpp";
list.Add(new LLM_Model(id));
}
return list;
}
catch { return new List<LLM_Model> { new LLM_Model("llama.cpp") }; }
}
public async Task<string> ChatAsync(string model, string prompt, string? systemPrompt = null, double? temperature = null)
{
try
{
var messages = new List<object>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new { role = "system", content = systemPrompt });
messages.Add(new { role = "user", content = prompt });
var request = new Dictionary<string, object?>
{
["model"] = model,
["messages"] = messages,
["stream"] = false
};
if (temperature.HasValue) request["temperature"] = Math.Clamp(temperature.Value, 0.0, 2.0);
var response = await _http.PostAsync(
$"{Url}/v1/chat/completions",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (!response.IsSuccessStatusCode) return "Fehler bei der Verarbeitung.";
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
{
var first = choices[0];
if (first.TryGetProperty("message", out var msg) && msg.TryGetProperty("content", out var text))
return text.GetString() ?? "";
}
return "Fehler bei der Verarbeitung.";
}
catch (Exception ex) { return $"Error: {ex.Message}"; }
}
public async Task ChatStreamAsync(string model, string prompt, Func<string, Task> onToken, string? systemPrompt = null, double? temperature = null)
{
var messages = new List<object>();
if (!string.IsNullOrEmpty(systemPrompt))
messages.Add(new { role = "system", content = systemPrompt });
messages.Add(new { role = "user", content = prompt });
var request = new Dictionary<string, object?>
{
["model"] = model,
["messages"] = messages,
["stream"] = true
};
if (temperature.HasValue) request["temperature"] = Math.Clamp(temperature.Value, 0.0, 2.0);
using var response = await _http.PostAsync(
$"{Url}/v1/chat/completions",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (!response.IsSuccessStatusCode) return;
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data: ")) continue;
var json = line[6..];
if (json == "[DONE]") break;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
{
var delta = choices[0].TryGetProperty("delta", out var d) ? d : default;
if (delta.ValueKind != JsonValueKind.Undefined && delta.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
await onToken(text);
}
}
}
catch { }
}
}
public async Task<float[]> GetEmbeddingAsync(string text, string? model = null)
{
try
{
var request = new { content = text };
var response = await _http.PostAsync(
$"{Url}/embedding",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (!response.IsSuccessStatusCode) return Array.Empty<float>();
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("embedding", out var emb)) return Array.Empty<float>();
var list = new List<float>();
foreach (var v in emb.EnumerateArray()) list.Add(v.GetSingle());
return list.ToArray();
}
catch { return Array.Empty<float>(); }
}
public Task<bool> PullModelAsync(string name, IProgress<string>? progress = null)
=> Task.FromResult(false);
public Task<bool> DeleteModelAsync(string name)
=> Task.FromResult(false);
public async Task<int?> GetContextSizeAsync(string model)
{
try
{
var response = await _http.GetAsync($"{Url}/slots");
if (!response.IsSuccessStatusCode) return 2048;
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.GetArrayLength() > 0)
{
var first = doc.RootElement[0];
if (first.TryGetProperty("n_ctx", out var ctx))
return ctx.GetInt32();
}
return 2048;
}
catch { return 2048; }
}
public async Task<long> MeasureLatencyAsync(string model)
{
try
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var response = await _http.GetAsync($"{Url}/health");
sw.Stop();
return response.IsSuccessStatusCode ? sw.ElapsedMilliseconds : -1;
}
catch { return -1; }
}
public async Task<bool> ModelExistsAsync(string model)
{
try
{
var models = await GetModelsAsync();
return models.Any();
}
catch { return false; }
}
public async Task<(bool Success, int ContextWindow)> GetModelContextWindowAsync(string model)
{
var ctx = await GetContextSizeAsync(model);
return (ctx.HasValue, ctx ?? 2048);
}
public bool TestEmbedding(string model)
{
try
{
var response = _http.PostAsync(
$"{Url}/embedding",
new StringContent(JsonSerializer.Serialize(new { content = "test" }), System.Text.Encoding.UTF8, "application/json")
).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode) return false;
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("embedding", out var emb))
return emb.EnumerateArray().Any();
return false;
}
catch { return false; }
}
}

View file

@ -113,6 +113,7 @@ public class LocalizationService : ILocalizationService
"nav.user.logged_in_as" => "Angemeldet als",
"nav.user.manage_2fa" => "2FA verwalten",
"nav.user.logout" => "Abmelden",
"assistant.exposee.context" => "Berücksichtige folgendes Exposé der Geschichte:",
_ => key,
};
}

253
Services/OllamaBackend.cs Normal file
View file

@ -0,0 +1,253 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Services;
public class OllamaBackend : ILLMBackend
{
private readonly HttpClient _http;
private readonly OllamaSettings _settings;
public string Name => "Ollama";
public bool SupportsModelManagement => true;
public OllamaBackend(OllamaSettings settings)
{
_settings = settings;
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
}
private string Url => _settings.OllamaUrl;
public async Task<bool> TestConnectionAsync()
{
try
{
var sw = Stopwatch.StartNew();
var response = await _http.GetAsync($"{Url}/api/tags");
sw.Stop();
return response.IsSuccessStatusCode;
}
catch { return false; }
}
public async Task<IEnumerable<LLM_Model>> GetModelsAsync()
{
try
{
var response = await _http.GetAsync($"{Url}/api/tags");
if (!response.IsSuccessStatusCode) return Enumerable.Empty<LLM_Model>();
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("models", out var models))
return Enumerable.Empty<LLM_Model>();
var list = new List<LLM_Model>();
foreach (var m in models.EnumerateArray())
list.Add(new LLM_Model(m));
return list;
}
catch { return Enumerable.Empty<LLM_Model>(); }
}
public async Task<string> ChatAsync(string model, string prompt, string? systemPrompt = null, double? temperature = null)
{
try
{
var request = new Dictionary<string, object?>
{
["model"] = model, ["prompt"] = prompt, ["stream"] = false
};
if (!string.IsNullOrEmpty(systemPrompt)) request["system"] = systemPrompt;
if (temperature.HasValue) request["temperature"] = Math.Clamp(temperature.Value, 0.0, 2.0);
var response = await _http.PostAsJsonAsync($"{Url}/api/generate", request);
if (!response.IsSuccessStatusCode) return "Fehler bei der Verarbeitung.";
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("response", out var resp))
return resp.GetString() ?? "";
return "Fehler bei der Verarbeitung.";
}
catch (Exception ex) { return $"Error: {ex.Message}"; }
}
public async Task ChatStreamAsync(string model, string prompt, Func<string, Task> onToken, string? systemPrompt = null, double? temperature = null)
{
var request = new Dictionary<string, object?>
{
["model"] = model, ["prompt"] = prompt, ["stream"] = true
};
if (!string.IsNullOrEmpty(systemPrompt)) request["system"] = systemPrompt;
if (temperature.HasValue) request["temperature"] = Math.Clamp(temperature.Value, 0.0, 2.0);
using var response = await _http.PostAsync(
$"{Url}/api/generate",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (!response.IsSuccessStatusCode) return;
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
var text = ExtractResponseText(line);
if (!string.IsNullOrEmpty(text))
await onToken(text);
}
}
private string ExtractResponseText(string jsonLine)
{
if (string.IsNullOrWhiteSpace(jsonLine)) return string.Empty;
try
{
using var doc = JsonDocument.Parse(jsonLine);
if (doc.RootElement.TryGetProperty("response", out var resp))
return resp.GetString() ?? string.Empty;
}
catch { }
return string.Empty;
}
public async Task<float[]> GetEmbeddingAsync(string text, string? model = null)
{
try
{
var m = model ?? _settings.EmbedModel;
var request = new { model = m, prompt = text };
var response = await _http.PostAsJsonAsync($"{Url}/api/embeddings", request);
if (!response.IsSuccessStatusCode) return Array.Empty<float>();
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (!doc.RootElement.TryGetProperty("embedding", out var emb)) return Array.Empty<float>();
var list = new List<float>();
foreach (var v in emb.EnumerateArray()) list.Add(v.GetSingle());
return list.ToArray();
}
catch { return Array.Empty<float>(); }
}
public async Task<bool> PullModelAsync(string name, IProgress<string>? progress = null)
{
try
{
var request = new { name, stream = true };
var response = await _http.PostAsync(
$"{Url}/api/pull",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (!response.IsSuccessStatusCode) return false;
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (root.TryGetProperty("status", out var status))
{
var st = status.GetString() ?? "";
var msg = st;
if (root.TryGetProperty("completed", out var c) && root.TryGetProperty("total", out var t))
{
var comp = c.GetInt64(); var tot = t.GetInt64();
if (tot > 0) msg = $"{st}: {comp}/{tot} ({(int)(comp * 100 / tot)}%)";
}
progress?.Report(msg);
if (st == "success") return true;
}
}
catch { }
}
return false;
}
catch { return false; }
}
public async Task<bool> DeleteModelAsync(string name)
{
try
{
var req = new HttpRequestMessage(HttpMethod.Delete, $"{Url}/api/delete")
{
Content = new StringContent(JsonSerializer.Serialize(new { name }), System.Text.Encoding.UTF8, "application/json")
};
var response = await _http.SendAsync(req);
return response.IsSuccessStatusCode;
}
catch { return false; }
}
public async Task<int?> GetContextSizeAsync(string model)
{
try
{
var response = await _http.PostAsJsonAsync($"{Url}/api/show", new { model });
if (!response.IsSuccessStatusCode) return null;
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("modelfile", out var mf))
{
var modelfile = mf.GetString() ?? "";
var match = Regex.Match(modelfile, @"num_ctx\s+(\d+)");
if (match.Success && int.TryParse(match.Groups[1].Value, out var ctx))
return ctx;
}
return 2048;
}
catch { return null; }
}
public async Task<long> MeasureLatencyAsync(string model)
{
try
{
var sw = Stopwatch.StartNew();
var request = new { model, prompt = "Hallo", stream = false };
var response = await _http.PostAsJsonAsync($"{Url}/api/generate", request);
sw.Stop();
return response.IsSuccessStatusCode ? sw.ElapsedMilliseconds : -1;
}
catch { return -1; }
}
public async Task<bool> ModelExistsAsync(string model)
{
if (string.IsNullOrWhiteSpace(model)) return false;
try
{
var models = await GetModelsAsync();
return models.Any(m => m.Name == model || m.Name.StartsWith(model.Split(':')[0]));
}
catch { return false; }
}
public async Task<(bool Success, int ContextWindow)> GetModelContextWindowAsync(string model)
{
var ctx = await GetContextSizeAsync(model);
return (ctx.HasValue, ctx ?? 0);
}
public bool TestEmbedding(string model)
{
try
{
var request = new { model, prompt = "test" };
var response = _http.PostAsJsonAsync($"{Url}/api/embeddings", request).GetAwaiter().GetResult();
if (!response.IsSuccessStatusCode) return false;
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("embedding", out var emb))
return emb.EnumerateArray().Any();
return false;
}
catch { return false; }
}
}

View file

@ -1,291 +1,115 @@
using AuthorBuddy.Web.Models;
using System.Text.Json;
using static System.Net.Mime.MediaTypeNames;
namespace AuthorBuddy.Web.Services;
public class OllamaService : IOllamaService
{
private readonly HttpClient _httpClient;
private readonly IEnumerable<ILLMBackend> _backends;
private readonly OllamaSettings _settings;
private string _baseUrl = "http://localhost:11434";
private readonly OperationState _opState;
public OllamaService(OllamaSettings settings)
public OllamaService(IEnumerable<ILLMBackend> backends, OllamaSettings settings, OperationState opState)
{
_backends = backends;
_settings = settings;
_baseUrl = settings.OllamaUrl;
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(300) };
_opState = opState;
}
private ILLMBackend ActiveBackend =>
_backends.FirstOrDefault(b => b.Name == _settings.Backend) ?? _backends.First();
public void Reconfigure(string url)
{
_baseUrl = url;
_settings.OllamaUrl = url;
}
public async Task<bool> TestEmbeddingAsync(string model)
public async Task<(bool Success, long LatencyMs)> TestConnectionAsync()
{
try
{
var request = new { model = model, prompt = "test" };
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}/api/embeddings", request);
if (!response.IsSuccessStatusCode) return false;
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("embedding", out var embedding))
return embedding.EnumerateArray().Any();
return false;
}
catch
{
return false;
}
}
public async Task<float[]> GetEmbeddingAsync(string text)
{
try
{
var request = new { model = _settings.EmbedModel, prompt = text };
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}/api/embeddings", request);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("embedding", out var embedding))
{
var list = new List<float>();
foreach (var val in embedding.EnumerateArray())
list.Add(val.GetSingle());
return list.ToArray();
}
}
return Array.Empty<float>();
}
catch
{
return Array.Empty<float>();
}
_opState.SetThought($"Teste Verbindung zu {ActiveBackend.Name}...");
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await ActiveBackend.TestConnectionAsync();
sw.Stop();
_opState.AppendThought(result ? " OK" : " Fehlgeschlagen");
return (result, result ? sw.ElapsedMilliseconds : 0);
}
public async Task<IEnumerable<LLM_Model>> GetLocalModelsAsync()
{
try
{
var response = await _httpClient.GetAsync($"{_baseUrl}/api/tags");
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
_opState.SetThought($"Lade Modelle von {ActiveBackend.Name}...");
var models = await ActiveBackend.GetModelsAsync();
_opState.AppendThought($" {models.Count()} gefunden");
return models;
}
if (root.TryGetProperty("models", out var models))
{
var modelList = new List<LLM_Model>();
foreach (var model in models.EnumerateArray())
{
modelList.Add(new LLM_Model(model));
public async Task<bool> TestEmbeddingAsync(string model)
{
return ActiveBackend.TestEmbedding(model);
}
}
return modelList;
}
}
return new List<LLM_Model>();
}
catch (Exception)
{
return new List<LLM_Model>();
}
public async Task<float[]> GetEmbeddingAsync(string text)
{
return await ActiveBackend.GetEmbeddingAsync(text, _settings.EmbedModel);
}
public async Task<string> QuickSpellCheckAsync(string text)
{
return await Chat(_settings.SpellingModel, text, _settings.SpellingPrompt);
return await Chat(_settings.SpellingModel, text, _settings.SpellingPrompt, _settings.SpellingTemp);
}
public async Task<string> StreamStyleCheckAsync(string text)
{
var systemPrompt = _settings.LektorSystemPrompt;
return await Chat(_settings.StyleModel, text, systemPrompt);
return await Chat(_settings.StyleModel, text, _settings.LektorSystemPrompt, _settings.LektorTemp);
}
public async Task<string> AnswerBasedOnFactsAsync(string question, string facts)
{
var prompt = _settings.GetFactCheckPrompt(question, facts);
return await Chat(_settings.EmbedModel, prompt);
return await Chat(_settings.EmbedModel, prompt, temperature: _settings.FactCheckTemp);
}
public async Task<string> Chat(string model, string userMessage, string? systemPrompt = null)
public async Task<bool> ModelExistsAsync(string model)
{
try
{
var request = new
{
model = model,
prompt = userMessage,
system = systemPrompt,
stream = false
};
var response = await _httpClient.PostAsJsonAsync($"{_baseUrl}/api/generate", request);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(content);
var root = doc.RootElement;
if (root.TryGetProperty("response", out var responseText))
{
return responseText.GetString() ?? "";
}
}
return "Fehler bei der Verarbeitung.";
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
}
return await ActiveBackend.ModelExistsAsync(model);
}
public async IAsyncEnumerable<string> ChatStream(string model, string userMessage, string? systemPrompt = null)
public async Task<(bool Success, int ContextWindow)> GetModelContextWindowAsync(string model)
{
var request = new
{
model = model,
prompt = userMessage,
stream = true
};
HttpResponseMessage? response = null;
try
{
response = await _httpClient.PostAsync(
$"{_baseUrl}/api/generate",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (response.IsSuccessStatusCode)
{
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
var text = ExtractResponseText(line);
if (!string.IsNullOrEmpty(text))
{
yield return text;
}
}
}
}
finally
{
response?.Dispose();
}
return await ActiveBackend.GetModelContextWindowAsync(model);
}
private string ExtractResponseText(string jsonLine)
public async Task<long> MeasureLatencyAsync(string model)
{
if (string.IsNullOrWhiteSpace(jsonLine))
return string.Empty;
try
{
using var doc = JsonDocument.Parse(jsonLine);
var root = doc.RootElement;
if (root.TryGetProperty("response", out var responseText))
{
return responseText.GetString() ?? string.Empty;
}
}
catch { }
return string.Empty;
return await ActiveBackend.MeasureLatencyAsync(model);
}
public async Task<bool> DeleteModel(string modelName)
public async Task<string> Chat(string model, string userMessage, string? systemPrompt = null, double? temperature = null)
{
try
{
var request = new HttpRequestMessage(HttpMethod.Delete, $"{_baseUrl}/api/delete");
request.Content = new StringContent(JsonSerializer.Serialize(new { name = modelName }),
System.Text.Encoding.UTF8, "application/json");
_opState.SetThought($"{ActiveBackend.Name}: Sende Anfrage an {model}...");
var result = await ActiveBackend.ChatAsync(model, userMessage, systemPrompt, temperature);
_opState.AppendThought(" Fertig");
return result;
}
var response = await _httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
catch (Exception)
public async Task ChatStream(string model, string userMessage, Func<string, Task> onToken, string? systemPrompt = null, double? temperature = null)
{
_opState.SetThought($"{ActiveBackend.Name}: Verbinde mit Modell {model}...");
int tokenCount = 0;
await ActiveBackend.ChatStreamAsync(model, userMessage, async chunk =>
{
return false;
}
tokenCount++;
_opState.AppendThought(chunk);
await onToken(chunk);
}, systemPrompt, temperature);
_opState.AppendThought($" Fertig ({tokenCount} Token)");
}
public async Task<bool> PullModelAsync(string modelName, IProgress<string> progress)
{
try
{
var request = new { name = modelName, stream = true };
var response = await _httpClient.PostAsync(
$"{_baseUrl}/api/pull",
new StringContent(JsonSerializer.Serialize(request), System.Text.Encoding.UTF8, "application/json")
);
if (response.IsSuccessStatusCode)
{
using var stream = await response.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (string.IsNullOrWhiteSpace(line)) continue;
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (root.TryGetProperty("status", out var status))
{
var statusText = status.GetString() ?? "";
var msg = statusText;
if (root.TryGetProperty("completed", out var completed) &&
root.TryGetProperty("total", out var total))
{
var comp = completed.GetInt64();
var tot = total.GetInt64();
if (tot > 0)
{
var pct = (int)(comp * 100 / tot);
msg = $"{statusText}: {comp}/{tot} ({pct}%)";
}
}
progress?.Report(msg);
if (statusText == "success")
return true;
}
}
catch { }
}
}
return false;
}
catch (Exception)
{
return false;
}
_opState.SetThought($"Lade Modell {modelName}...");
var result = await ActiveBackend.PullModelAsync(modelName, progress);
_opState.AppendThought(result ? " Erfolgreich" : " Fehlgeschlagen");
return result;
}
}

View file

@ -0,0 +1,123 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using AuthorBuddy.Web.Models;
namespace AuthorBuddy.Web.Services;
public class OllamaSetupService : IOllamaSetupService
{
private readonly IOllamaService _ollamaService;
private readonly OperationState _opState;
private readonly OllamaSettings _currentSettings;
public OllamaSetupService(IOllamaService ollamaService, OperationState opState, OllamaSettings currentSettings)
{
_ollamaService = ollamaService;
_opState = opState;
_currentSettings = currentSettings;
}
public async Task<SetupRecommendation> AnalyzeSettingsAsync()
{
var recommendation = new SetupRecommendation();
var recommended = new OllamaSettings();
_opState.SetStatus("Überprüfe Ollama-Einstellungen...", true);
_opState.SetThought("");
// OS Detection
string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Windows" :
RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "Linux" : "Unbekannt";
_opState.AppendThought($"System erkannt: {os}");
recommendation.Findings.Add($"Betriebssystem: {os}");
// Connection Check
_opState.AppendThought("Prüfe Verbindung zu Ollama...");
var connection = await _ollamaService.TestConnectionAsync();
if (connection.Success)
{
_opState.AppendThought("Verbindung erfolgreich.");
recommendation.Findings.Add("Ollama ist erreichbar.");
recommended.OllamaUrl = _currentSettings.OllamaUrl;
}
else
{
_opState.AppendThought("Verbindung fehlgeschlagen!");
recommendation.Findings.Add("Ollama ist NICHT erreichbar. Bitte prüfen Sie, ob der Server läuft.");
recommended.OllamaUrl = "http://localhost:11434";
recommendation.IsCorrect = false;
}
// Model Recommendations
_opState.AppendThought("Analysiere Modell-Empfehlungen...");
// Recommendation logic
recommended.EmbedModel = "nomic-embed-text";
recommended.StyleModel = "gemma4:e4b";
recommended.SpellingModel = "phi3:mini";
if (_currentSettings.EmbedModel != recommended.EmbedModel)
{
recommendation.Findings.Add($"Empfehlung: Nutze {recommended.EmbedModel} für Embeddings (aktuell: {_currentSettings.EmbedModel}).");
recommendation.IsCorrect = false;
}
if (_currentSettings.StyleModel != recommended.StyleModel)
{
recommendation.Findings.Add($"Empfehlung: Nutze {recommended.StyleModel} für Stil-Optimierung (aktuell: {_currentSettings.StyleModel}).");
recommendation.IsCorrect = false;
}
if (_currentSettings.SpellingModel != recommended.SpellingModel)
{
recommendation.Findings.Add($"Empfehlung: Nutze {recommended.SpellingModel} für Rechtschreibung (aktuell: {_currentSettings.SpellingModel}).");
recommendation.IsCorrect = false;
}
if (recommendation.IsCorrect == false)
{
recommendation.Message = "Es wurden Optimierungspotenziale in Ihren Ollama-Einstellungen gefunden.";
}
else
{
recommendation.IsCorrect = true;
recommendation.Message = "Alle Ollama-Einstellungen sind optimal konfiguriert.";
}
recommended.LektorSystemPrompt = _currentSettings.LektorSystemPrompt;
recommended.FactCheckTemplate = _currentSettings.FactCheckTemplate;
recommended.SpellingPrompt = _currentSettings.SpellingPrompt;
recommended.Language = _currentSettings.Language;
_opState.SetStatus("Überprüfung abgeschlossen", false);
recommendation.RecommendedSettings = recommended;
return recommendation;
}
public async Task<bool> CreateConfigFileAsync(OllamaSettings settings)
{
try
{
_opState.SetStatus("Erstelle Konfigurationsdatei...", true);
_opState.SetThought("Schreibe ollama_config.json in das App-Verzeichnis...");
var options = new JsonSerializerOptions { WriteIndented = true };
var json = JsonSerializer.Serialize(settings, options);
// Writing to app root
await File.WriteAllTextAsync("ollama_config.json", json);
_opState.SetStatus("Konfigurationsdatei erstellt", false);
_opState.SetThought("Datei 'ollama_config.json' wurde erfolgreich gespeichert.");
return true;
}
catch (Exception ex)
{
_opState.SetStatus("Fehler beim Erstellen der Datei", false);
_opState.SetThought($"Error: {ex.Message}");
return false;
}
}
}

View file

@ -0,0 +1,42 @@
namespace AuthorBuddy.Web.Services;
public class OperationState
{
public string StatusText { get; private set; } = "Bereit";
public bool IsBusy { get; private set; }
public double Progress { get; private set; }
public string ThoughtText { get; private set; } = "";
public event Action? OnChange;
public void SetStatus(string text, bool busy = false, double progress = 0)
{
StatusText = text;
IsBusy = busy;
Progress = progress;
Notify();
}
public void SetThought(string text)
{
ThoughtText = text;
Notify();
}
public void AppendThought(string text)
{
ThoughtText += text;
Notify();
}
public void Reset()
{
StatusText = "Bereit";
IsBusy = false;
Progress = 0;
ThoughtText = "";
Notify();
}
private void Notify() => OnChange?.Invoke();
}

View file

@ -15,39 +15,91 @@ public class ProjectService : IProjectService
_fileScannerService = fileScannerService;
}
public Task<int> CreateNewProjectAsync(string projectName, string rootPath)
{
string[] subFolders = { "00_Drafts", "01_Fakten", "02_Characters", "03_Archive" };
foreach (var subFolder in subFolders)
{
Directory.CreateDirectory(Path.Combine(rootPath, subFolder));
}
private ILiteCollection<FileCategoryEntity> CategoryCol =>
_db.GetCollection<FileCategoryEntity>("fileCategories");
string readmePath = Path.Combine(rootPath, "00_Projekt_Info.md");
File.WriteAllText(readmePath, $"# Projekt: {projectName}\nErstellt am: {DateTime.Now}");
public Task<int> CreateNewProjectAsync(string projectName, string rootPath, int userId, ProjectTemplate template = ProjectTemplate.Novel)
{
if (template == ProjectTemplate.Technical)
{
string[] subFolders = { "00_Anforderungen", "01_UseCases", "02_Architektur", "03_Glossar" };
foreach (var subFolder in subFolders)
{
Directory.CreateDirectory(Path.Combine(rootPath, subFolder));
}
string readmePath = Path.Combine(rootPath, "00_Projekt_Info.md");
File.WriteAllText(readmePath, $"# Projekt: {projectName}\nErstellt am: {DateTime.Now}\nTyp: Technische Vorlage (Requirements)");
string lastenheftPath = Path.Combine(rootPath, "Lastenheft.md");
File.WriteAllText(lastenheftPath, $"# {projectName} Lastenheft\n\n" +
"## 1. Zielbestimmung\n\n" +
"## 2. Produkteinsatz\n\n" +
"## 3. Produktumgebung\n\n" +
"## 4. Funktionale Anforderungen\n\n" +
"## 5. Nicht-funktionale Anforderungen\n\n" +
"## 6. Lieferumfang\n\n");
string pflichtenheftPath = Path.Combine(rootPath, "Pflichtenheft.md");
File.WriteAllText(pflichtenheftPath, $"# {projectName} Pflichtenheft\n\n" +
"## 1. Systemarchitektur\n\n" +
"## 2. Komponentenbeschreibung\n\n" +
"## 3. Schnittstellendefinition\n\n" +
"## 4. Datenmodell\n\n" +
"## 5. Technische Umsetzung\n\n");
}
else
{
string[] subFolders = { "00_Drafts", "01_Fakten", "02_Characters", "03_Archive" };
foreach (var subFolder in subFolders)
{
Directory.CreateDirectory(Path.Combine(rootPath, subFolder));
}
string readmePath = Path.Combine(rootPath, "00_Projekt_Info.md");
File.WriteAllText(readmePath, $"# Projekt: {projectName}\nErstellt am: {DateTime.Now}");
string exposeePath = Path.Combine(rootPath, "Die Handlung (Das Exposé).md");
File.WriteAllText(exposeePath, $"# {projectName} Die Handlung (Das Exposé)\n\n" +
"## Überblick\n\n" +
"## Erster Akt (Aufbau)\n\n" +
"## Zweiter Akt (Konfrontation)\n\n" +
"## Dritter Akt (Auflösung)\n\n" +
"## Wichtige Wendepunkte\n\n" +
"## Charakterbögen\n\n");
string ideasPath = Path.Combine(rootPath, "Ideen Sammlung.md");
File.WriteAllText(ideasPath, $"# Ideen Sammlung {projectName}\n\n");
}
var col = _db.GetCollection<ProjectEntity>("projects");
var entity = new ProjectEntity
{
Name = projectName,
RootPath = rootPath,
LastOpened = DateTime.Now
UserId = userId,
LastOpened = DateTime.Now,
Template = template
};
col.Insert(entity);
return Task.FromResult(entity.Id);
}
public Task<List<Project>> GetAllProjectsAsync()
public Task<List<Project>> GetAllProjectsAsync(int? userId = null)
{
var col = _db.GetCollection<ProjectEntity>("projects");
var list = col.FindAll()
var query = userId == null
? col.FindAll()
: col.Find(p => p.UserId == userId);
var list = query
.OrderByDescending(p => p.LastOpened)
.Select(p => new Project
{
Id = p.Id,
Name = p.Name,
RootPath = p.RootPath
RootPath = p.RootPath,
Template = p.Template
})
.ToList();
return Task.FromResult(list);
@ -62,23 +114,25 @@ public class ProjectService : IProjectService
{
Id = entity.Id,
Name = entity.Name,
RootPath = entity.RootPath
RootPath = entity.RootPath,
Template = entity.Template
});
}
public Task<List<FileTreeItem>> GetProjectFileTreeAsync(int projectId)
public async Task<List<FileTreeItem>> GetProjectFileTreeAsync(int projectId)
{
var col = _db.GetCollection<ProjectEntity>("projects");
var entity = col.FindById(projectId);
if (entity == null || !Directory.Exists(entity.RootPath))
return Task.FromResult(new List<FileTreeItem>());
return new List<FileTreeItem>();
var categories = await GetFileCategoriesAsync(projectId);
var result = new List<FileTreeItem>();
BuildTree(entity.RootPath, result, 0);
return Task.FromResult(result);
BuildTree(entity.RootPath, result, 0, categories);
return result;
}
private static void BuildTree(string directory, List<FileTreeItem> items, int depth)
private static void BuildTree(string directory, List<FileTreeItem> items, int depth, Dictionary<string, FileCategory> categories)
{
if (depth > 4) return;
@ -93,18 +147,20 @@ public class ProjectService : IProjectService
FullPath = dir.FullName,
IsDirectory = true
};
BuildTree(dir.FullName, node.Children, depth + 1);
BuildTree(dir.FullName, node.Children, depth + 1, categories);
if (node.Children.Count > 0 || dir.Name.StartsWith("00_") || dir.Name.StartsWith("01_") || dir.Name.StartsWith("02_"))
items.Add(node);
}
foreach (var file in dirInfo.GetFiles("*.md").OrderBy(f => f.Name))
{
categories.TryGetValue(file.FullName, out var cat);
items.Add(new FileTreeItem
{
Name = file.Name,
FullPath = file.FullName,
IsDirectory = false
IsDirectory = false,
Category = cat
});
}
}
@ -128,7 +184,9 @@ public class ProjectService : IProjectService
{
Name = newName,
RootPath = newRootPath,
LastOpened = DateTime.Now
UserId = userId,
LastOpened = DateTime.Now,
Template = source.Template
};
col.Insert(entity);
@ -161,4 +219,57 @@ public class ProjectService : IProjectService
File.Copy(file, dest, true);
}
}
public Task<Dictionary<string, FileCategory>> GetFileCategoriesAsync(int projectId)
{
var entries = CategoryCol.Find(e => e.ProjectId == projectId).ToList();
var result = new Dictionary<string, FileCategory>(StringComparer.OrdinalIgnoreCase);
foreach (var e in entries)
result[e.FilePath] = e.Category;
return Task.FromResult(result);
}
public Task<bool> SetFileCategoryAsync(int projectId, string filePath, FileCategory category)
{
if (!FileCategoryHelper.IsValid(category)) return Task.FromResult(false);
if (category.HasFlag(FileCategory.Exposee))
{
var existingExposee = CategoryCol.Find(e => e.ProjectId == projectId)
.FirstOrDefault(e => e.Category.HasFlag(FileCategory.Exposee) && e.FilePath != filePath);
if (existingExposee != null) return Task.FromResult(false);
}
var col = CategoryCol;
var existing = col.FindOne(e => e.ProjectId == projectId && e.FilePath == filePath);
if (existing != null)
{
if (category == FileCategory.None)
col.Delete(existing.Id);
else
{
existing.Category = category;
col.Update(existing);
}
}
else if (category != FileCategory.None)
{
col.Insert(new FileCategoryEntity
{
ProjectId = projectId,
FilePath = filePath,
Category = category
});
}
return Task.FromResult(true);
}
public Task ClearFileCategoryAsync(int projectId, string filePath)
{
var col = CategoryCol;
var existing = col.FindOne(e => e.ProjectId == projectId && e.FilePath == filePath);
if (existing != null)
col.Delete(existing.Id);
return Task.CompletedTask;
}
}

View file

@ -7,6 +7,8 @@ namespace AuthorBuddy.Web.Services;
public class SettingsService : ISettingsService
{
private const string KeyOllamaUrl = "ollama_url";
private const string KeyLlamaCppUrl = "llama_cpp_url";
private const string KeyBackend = "llm_backend";
private const string KeyRagModel = "ollama_rag_model";
private const string KeyStyleModel = "ollama_style_model";
private const string KeySpellingModel = "ollama_spelling_model";
@ -17,6 +19,28 @@ public class SettingsService : ISettingsService
private const string KeyAdvocatusDiaboli = "ollama_advocatus_diaboli";
private const string KeyHookFinder = "ollama_hook_finder";
private const string KeyEli5 = "ollama_eli5";
private const string KeyCoAuthor = "ollama_coauthor";
private const string KeyLektorTemp = "ollama_lektor_temp";
private const string KeyFactCheckTemp = "ollama_fact_check_temp";
private const string KeySpellingTemp = "ollama_spelling_temp";
private const string KeyAdvocatusDiaboliTemp = "ollama_advocatus_diaboli_temp";
private const string KeyHookFinderTemp = "ollama_hook_finder_temp";
private const string KeyEli5Temp = "ollama_eli5_temp";
private const string KeyCoAuthorTemp = "ollama_coauthor_temp";
private const string KeyTechLastenheft = "tech_lastenheft";
private const string KeyTechPflichtenheft = "tech_pflichtenheft";
private const string KeyTechUseCase = "tech_usecase";
private const string KeyTechStakeholder = "tech_stakeholder";
private const string KeyTechGlossar = "tech_glossar";
private const string KeyTechQualitaet = "tech_qualitaet";
private const string KeyTechRisiko = "tech_risiko";
private const string KeyTechLastenheftTemp = "tech_lastenheft_temp";
private const string KeyTechPflichtenheftTemp = "tech_pflichtenheft_temp";
private const string KeyTechUseCaseTemp = "tech_usecase_temp";
private const string KeyTechStakeholderTemp = "tech_stakeholder_temp";
private const string KeyTechGlossarTemp = "tech_glossar_temp";
private const string KeyTechQualitaetTemp = "tech_qualitaet_temp";
private const string KeyTechRisikoTemp = "tech_risiko_temp";
private const string KeyLanguage = "language";
private const string KeyLastProjectId = "last_project_id";
@ -93,6 +117,27 @@ public class SettingsService : ISettingsService
return GetSetting(key, _currentUserId, _currentProjectId);
}
private string? GetGlobal(string key)
{
var col = _db.GetCollection<SettingEntity>("settings");
return col.FindOne(s => s.Key == key && s.UserId == null && s.ProjectId == null)?.Value;
}
private void SetGlobal(string key, string value)
{
var col = _db.GetCollection<SettingEntity>("settings");
var existing = col.FindOne(s => s.Key == key && s.UserId == null && s.ProjectId == null);
if (existing != null)
{
existing.Value = value;
col.Update(existing);
}
else
{
col.Insert(new SettingEntity { Key = key, Value = value, UserId = null, ProjectId = null });
}
}
private void Set(string key, string value)
{
var col = _db.GetCollection<SettingEntity>("settings");
@ -117,16 +162,41 @@ public class SettingsService : ISettingsService
}
}
public Task<string?> GetOllamaUrlAsync() => Task.FromResult(Get(KeyOllamaUrl));
public Task<string?> GetRagModelAsync() => Task.FromResult(Get(KeyRagModel));
public Task<string?> GetStyleModelAsync() => Task.FromResult(Get(KeyStyleModel));
public Task<string?> GetOllamaUrlAsync() => Task.FromResult(GetGlobal(KeyOllamaUrl));
public Task<string?> GetLlamaCppUrlAsync() => Task.FromResult(GetGlobal(KeyLlamaCppUrl));
public Task<string?> GetBackendAsync() => Task.FromResult(GetGlobal(KeyBackend));
public Task<string?> GetRagModelAsync() => Task.FromResult(GetGlobal(KeyRagModel));
public Task<string?> GetStyleModelAsync() => Task.FromResult(GetGlobal(KeyStyleModel));
public Task<string?> GetLektorSystemPromptAsync() => Task.FromResult(Get(KeyLektorPrompt));
public Task<string?> GetFactCheckTemplateAsync() => Task.FromResult(Get(KeyFactCheckTemplate));
public Task<string?> GetSpellingPromptAsync() => Task.FromResult(Get(KeySpellingPrompt));
public Task<string?> GetSpellingModelAsync() => Task.FromResult(Get(KeySpellingModel));
public Task<string?> GetSpellingModelAsync() => Task.FromResult(GetGlobal(KeySpellingModel));
public Task<string?> GetAdvocatusDiaboliPromptAsync() => Task.FromResult(Get(KeyAdvocatusDiaboli));
public Task<string?> GetHookFinderPromptAsync() => Task.FromResult(Get(KeyHookFinder));
public Task<string?> GetEli5PromptAsync() => Task.FromResult(Get(KeyEli5));
public Task<string?> GetCoAuthorBrainstormingPromptAsync() => Task.FromResult(Get(KeyCoAuthor));
public Task<double> GetLektorTempAsync() => Task.FromResult(double.TryParse(Get(KeyLektorTemp), out var v) ? v : _ollamaConfig.LektorTemp);
public Task<double> GetFactCheckTempAsync() => Task.FromResult(double.TryParse(Get(KeyFactCheckTemp), out var v) ? v : _ollamaConfig.FactCheckTemp);
public Task<double> GetSpellingTempAsync() => Task.FromResult(double.TryParse(Get(KeySpellingTemp), out var v) ? v : _ollamaConfig.SpellingTemp);
public Task<double> GetAdvocatusDiaboliTempAsync() => Task.FromResult(double.TryParse(Get(KeyAdvocatusDiaboliTemp), out var v) ? v : _ollamaConfig.AdvocatusDiaboliTemp);
public Task<double> GetHookFinderTempAsync() => Task.FromResult(double.TryParse(Get(KeyHookFinderTemp), out var v) ? v : _ollamaConfig.HookFinderTemp);
public Task<double> GetEli5TempAsync() => Task.FromResult(double.TryParse(Get(KeyEli5Temp), out var v) ? v : _ollamaConfig.Eli5Temp);
public Task<double> GetCoAuthorTempAsync() => Task.FromResult(double.TryParse(Get(KeyCoAuthorTemp), out var v) ? v : _ollamaConfig.CoAuthorTemp);
public Task<string?> GetTechLastenheftPromptAsync() => Task.FromResult(Get(KeyTechLastenheft));
public Task<string?> GetTechPflichtenheftPromptAsync() => Task.FromResult(Get(KeyTechPflichtenheft));
public Task<string?> GetTechUseCasePromptAsync() => Task.FromResult(Get(KeyTechUseCase));
public Task<string?> GetTechStakeholderPromptAsync() => Task.FromResult(Get(KeyTechStakeholder));
public Task<string?> GetTechGlossarPromptAsync() => Task.FromResult(Get(KeyTechGlossar));
public Task<string?> GetTechQualitaetPromptAsync() => Task.FromResult(Get(KeyTechQualitaet));
public Task<string?> GetTechRisikoPromptAsync() => Task.FromResult(Get(KeyTechRisiko));
public Task<double> GetTechLastenheftTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechLastenheftTemp), out var v) ? v : _ollamaConfig.TechLastenheftTemp);
public Task<double> GetTechPflichtenheftTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechPflichtenheftTemp), out var v) ? v : _ollamaConfig.TechPflichtenheftTemp);
public Task<double> GetTechUseCaseTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechUseCaseTemp), out var v) ? v : _ollamaConfig.TechUseCaseTemp);
public Task<double> GetTechStakeholderTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechStakeholderTemp), out var v) ? v : _ollamaConfig.TechStakeholderTemp);
public Task<double> GetTechGlossarTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechGlossarTemp), out var v) ? v : _ollamaConfig.TechGlossarTemp);
public Task<double> GetTechQualitaetTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechQualitaetTemp), out var v) ? v : _ollamaConfig.TechQualitaetTemp);
public Task<double> GetTechRisikoTempAsync() => Task.FromResult(double.TryParse(Get(KeyTechRisikoTemp), out var v) ? v : _ollamaConfig.TechRisikoTemp);
public Task<string?> GetThemeAsync() => Task.FromResult(Get(KeyTheme));
public Task<string?> GetLanguageAsync() => Task.FromResult(Get(KeyLanguage));
public Task SaveLanguageAsync(string language)
@ -138,21 +208,35 @@ public class SettingsService : ISettingsService
public Task SaveOllamaUrlAsync(string url)
{
Set(KeyOllamaUrl, url);
SetGlobal(KeyOllamaUrl, url);
_ollamaConfig.OllamaUrl = url;
return Task.CompletedTask;
}
public Task SaveLlamaCppUrlAsync(string url)
{
SetGlobal(KeyLlamaCppUrl, url);
_ollamaConfig.LlamaCppUrl = url;
return Task.CompletedTask;
}
public Task SaveBackendAsync(string backend)
{
SetGlobal(KeyBackend, backend);
_ollamaConfig.Backend = backend;
return Task.CompletedTask;
}
public Task SaveRagModelAsync(string model)
{
Set(KeyRagModel, model);
SetGlobal(KeyRagModel, model);
_ollamaConfig.EmbedModel = model;
return Task.CompletedTask;
}
public Task SaveStyleModelAsync(string model)
{
Set(KeyStyleModel, model);
SetGlobal(KeyStyleModel, model);
_ollamaConfig.StyleModel = model;
return Task.CompletedTask;
}
@ -180,7 +264,7 @@ public class SettingsService : ISettingsService
public Task SaveSpellingModelAsync(string model)
{
Set(KeySpellingModel, model);
SetGlobal(KeySpellingModel, model);
_ollamaConfig.SpellingModel = model;
return Task.CompletedTask;
}
@ -206,6 +290,160 @@ public class SettingsService : ISettingsService
return Task.CompletedTask;
}
public Task SaveCoAuthorBrainstormingPromptAsync(string prompt)
{
Set(KeyCoAuthor, prompt);
_ollamaConfig.CoAuthorBrainstormingPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveLektorTempAsync(double temp)
{
Set(KeyLektorTemp, temp.ToString("F1"));
_ollamaConfig.LektorTemp = temp;
return Task.CompletedTask;
}
public Task SaveFactCheckTempAsync(double temp)
{
Set(KeyFactCheckTemp, temp.ToString("F1"));
_ollamaConfig.FactCheckTemp = temp;
return Task.CompletedTask;
}
public Task SaveSpellingTempAsync(double temp)
{
Set(KeySpellingTemp, temp.ToString("F1"));
_ollamaConfig.SpellingTemp = temp;
return Task.CompletedTask;
}
public Task SaveAdvocatusDiaboliTempAsync(double temp)
{
Set(KeyAdvocatusDiaboliTemp, temp.ToString("F1"));
_ollamaConfig.AdvocatusDiaboliTemp = temp;
return Task.CompletedTask;
}
public Task SaveHookFinderTempAsync(double temp)
{
Set(KeyHookFinderTemp, temp.ToString("F1"));
_ollamaConfig.HookFinderTemp = temp;
return Task.CompletedTask;
}
public Task SaveEli5TempAsync(double temp)
{
Set(KeyEli5Temp, temp.ToString("F1"));
_ollamaConfig.Eli5Temp = temp;
return Task.CompletedTask;
}
public Task SaveCoAuthorTempAsync(double temp)
{
Set(KeyCoAuthorTemp, temp.ToString("F1"));
_ollamaConfig.CoAuthorTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechLastenheftPromptAsync(string prompt)
{
Set(KeyTechLastenheft, prompt);
_ollamaConfig.TechLastenheftPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechPflichtenheftPromptAsync(string prompt)
{
Set(KeyTechPflichtenheft, prompt);
_ollamaConfig.TechPflichtenheftPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechUseCasePromptAsync(string prompt)
{
Set(KeyTechUseCase, prompt);
_ollamaConfig.TechUseCasePrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechStakeholderPromptAsync(string prompt)
{
Set(KeyTechStakeholder, prompt);
_ollamaConfig.TechStakeholderPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechGlossarPromptAsync(string prompt)
{
Set(KeyTechGlossar, prompt);
_ollamaConfig.TechGlossarPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechQualitaetPromptAsync(string prompt)
{
Set(KeyTechQualitaet, prompt);
_ollamaConfig.TechQualitaetPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechRisikoPromptAsync(string prompt)
{
Set(KeyTechRisiko, prompt);
_ollamaConfig.TechRisikoPrompt = prompt;
return Task.CompletedTask;
}
public Task SaveTechLastenheftTempAsync(double temp)
{
Set(KeyTechLastenheftTemp, temp.ToString("F1"));
_ollamaConfig.TechLastenheftTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechPflichtenheftTempAsync(double temp)
{
Set(KeyTechPflichtenheftTemp, temp.ToString("F1"));
_ollamaConfig.TechPflichtenheftTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechUseCaseTempAsync(double temp)
{
Set(KeyTechUseCaseTemp, temp.ToString("F1"));
_ollamaConfig.TechUseCaseTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechStakeholderTempAsync(double temp)
{
Set(KeyTechStakeholderTemp, temp.ToString("F1"));
_ollamaConfig.TechStakeholderTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechGlossarTempAsync(double temp)
{
Set(KeyTechGlossarTemp, temp.ToString("F1"));
_ollamaConfig.TechGlossarTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechQualitaetTempAsync(double temp)
{
Set(KeyTechQualitaetTemp, temp.ToString("F1"));
_ollamaConfig.TechQualitaetTemp = temp;
return Task.CompletedTask;
}
public Task SaveTechRisikoTempAsync(double temp)
{
Set(KeyTechRisikoTemp, temp.ToString("F1"));
_ollamaConfig.TechRisikoTemp = temp;
return Task.CompletedTask;
}
public Task SaveThemeAsync(string theme)
{
Set(KeyTheme, theme);
@ -251,54 +489,138 @@ public class SettingsService : ISettingsService
{
const string schemaKey = "_schema_version";
var settings = _db.GetCollection<SettingEntity>("settings");
if (settings.FindOne(s => s.Key == schemaKey && s.UserId == null && s.ProjectId == null) != null)
return;
var schemaSetting = settings.FindOne(s => s.Key == schemaKey && s.UserId == null && s.ProjectId == null);
var currentVersion = int.TryParse(schemaSetting?.Value, out var v) ? v : 0;
var users = _db.GetCollection<UserEntity>("users");
var all = users.FindAll().OrderBy(u => u.Id).ToList();
if (all.Count > 0)
if (currentVersion < 1)
{
all[0].Role = UserRole.Administrator;
foreach (var u in all)
var users = _db.GetCollection<UserEntity>("users");
var all = users.FindAll().OrderBy(u => u.Id).ToList();
if (all.Count > 0)
{
u.IsActive = true;
users.Update(u);
all[0].Role = UserRole.Administrator;
foreach (var u in all)
{
u.IsActive = true;
users.Update(u);
}
}
}
var authorExists = all.Any(u => u.Name == "Author");
if (!authorExists)
{
var authorUser = new UserEntity
var authorExists = all.Any(u => u.Name == "Author");
if (!authorExists)
{
Name = "Author",
PasswordHash = TotpHelper.HashPassword("author123"),
TwoFactorEnabled = false,
TwoFactorSecret = string.Empty,
Role = UserRole.Author,
IsActive = true
};
users.Insert(authorUser);
var authorUser = new UserEntity
{
Name = "Author",
PasswordHash = TotpHelper.HashPassword("author123"),
TwoFactorEnabled = false,
TwoFactorSecret = string.Empty,
Role = UserRole.Author,
IsActive = true
};
users.Insert(authorUser);
}
var projects = _db.GetCollection<ProjectEntity>("projects");
var orphaned = projects.Find(p => p.UserId == 0).ToList();
if (orphaned.Count > 0)
{
var admin = users.FindAll().OrderBy(u => u.Id).FirstOrDefault();
if (admin != null)
{
foreach (var p in orphaned)
{
p.UserId = admin.Id;
projects.Update(p);
}
}
}
currentVersion = 1;
}
settings.Insert(new SettingEntity
if (currentVersion < 2)
{
Key = schemaKey,
Value = "1",
UserId = null,
ProjectId = null
});
var llmKeys = new[] { KeyOllamaUrl, KeyRagModel, KeyStyleModel, KeySpellingModel };
foreach (var key in llmKeys)
{
var perUser = settings.Find(s => s.Key == key && s.UserId != null).ToList();
foreach (var s in perUser)
settings.Delete(s.Id);
if (settings.FindOne(s => s.Key == key && s.UserId == null && s.ProjectId == null) == null)
{
var defaultValue = key switch
{
var k when k == KeyOllamaUrl => _ollamaConfig.OllamaUrl,
var k when k == KeyRagModel => _ollamaConfig.EmbedModel,
var k when k == KeyStyleModel => _ollamaConfig.StyleModel,
var k when k == KeySpellingModel => _ollamaConfig.SpellingModel,
_ => ""
};
settings.Insert(new SettingEntity { Key = key, Value = defaultValue, UserId = null, ProjectId = null });
}
}
currentVersion = 2;
}
if (schemaSetting != null)
{
schemaSetting.Value = currentVersion.ToString();
settings.Update(schemaSetting);
}
else
{
settings.Insert(new SettingEntity { Key = schemaKey, Value = currentVersion.ToString(), UserId = null, ProjectId = null });
}
}
public Task LoadProjectSettingsAsync()
{
var settings = _db.GetCollection<SettingEntity>("settings");
var entries = new (string Key, Action<string> Setter)[]
var globalKeys = new[] { KeyOllamaUrl, KeyLlamaCppUrl, KeyBackend, KeyRagModel, KeyStyleModel, KeySpellingModel };
foreach (var key in globalKeys)
{
var val = GetGlobal(key);
if (val == null) continue;
switch (key)
{
case var k when k == KeyOllamaUrl: _ollamaConfig.OllamaUrl = val; break;
case var k when k == KeyLlamaCppUrl: _ollamaConfig.LlamaCppUrl = val; break;
case var k when k == KeyBackend: _ollamaConfig.Backend = val; break;
case var k when k == KeyRagModel: _ollamaConfig.EmbedModel = val; break;
case var k when k == KeyStyleModel: _ollamaConfig.StyleModel = val; break;
case var k when k == KeySpellingModel: _ollamaConfig.SpellingModel = val; break;
}
}
var tempMap = new (string Key, Action<double> Setter)[]
{
(KeyLektorTemp, val => _ollamaConfig.LektorTemp = val),
(KeyFactCheckTemp, val => _ollamaConfig.FactCheckTemp = val),
(KeySpellingTemp, val => _ollamaConfig.SpellingTemp = val),
(KeyAdvocatusDiaboliTemp, val => _ollamaConfig.AdvocatusDiaboliTemp = val),
(KeyHookFinderTemp, val => _ollamaConfig.HookFinderTemp = val),
(KeyEli5Temp, val => _ollamaConfig.Eli5Temp = val),
(KeyCoAuthorTemp, val => _ollamaConfig.CoAuthorTemp = val),
(KeyTechLastenheftTemp, val => _ollamaConfig.TechLastenheftTemp = val),
(KeyTechPflichtenheftTemp, val => _ollamaConfig.TechPflichtenheftTemp = val),
(KeyTechUseCaseTemp, val => _ollamaConfig.TechUseCaseTemp = val),
(KeyTechStakeholderTemp, val => _ollamaConfig.TechStakeholderTemp = val),
(KeyTechGlossarTemp, val => _ollamaConfig.TechGlossarTemp = val),
(KeyTechQualitaetTemp, val => _ollamaConfig.TechQualitaetTemp = val),
(KeyTechRisikoTemp, val => _ollamaConfig.TechRisikoTemp = val),
};
foreach (var entry in tempMap)
{
var val = Get(entry.Key);
if (val != null && double.TryParse(val, out var d))
entry.Setter(d);
}
var userEntries = new (string Key, Action<string> Setter)[]
{
(KeyOllamaUrl, val => _ollamaConfig.OllamaUrl = val),
(KeyRagModel, val => _ollamaConfig.EmbedModel = val),
(KeyStyleModel, val => _ollamaConfig.StyleModel = val),
(KeySpellingModel, val => _ollamaConfig.SpellingModel = val),
(KeyTheme, val => _ollamaConfig.Theme = val),
(KeyLektorPrompt, val => _ollamaConfig.LektorSystemPrompt = val),
(KeyFactCheckTemplate, val => _ollamaConfig.FactCheckTemplate = val),
@ -306,10 +628,18 @@ public class SettingsService : ISettingsService
(KeyAdvocatusDiaboli, val => _ollamaConfig.AdvocatusDiaboliPrompt = val),
(KeyHookFinder, val => _ollamaConfig.HookFinderPrompt = val),
(KeyEli5, val => _ollamaConfig.Eli5Prompt = val),
(KeyCoAuthor, val => _ollamaConfig.CoAuthorBrainstormingPrompt = val),
(KeyTechLastenheft, val => _ollamaConfig.TechLastenheftPrompt = val),
(KeyTechPflichtenheft, val => _ollamaConfig.TechPflichtenheftPrompt = val),
(KeyTechUseCase, val => _ollamaConfig.TechUseCasePrompt = val),
(KeyTechStakeholder, val => _ollamaConfig.TechStakeholderPrompt = val),
(KeyTechGlossar, val => _ollamaConfig.TechGlossarPrompt = val),
(KeyTechQualitaet, val => _ollamaConfig.TechQualitaetPrompt = val),
(KeyTechRisiko, val => _ollamaConfig.TechRisikoPrompt = val),
(KeyLanguage, val => _ollamaConfig.Language = val),
};
foreach (var entry in entries)
foreach (var entry in userEntries)
{
var val = Get(entry.Key);
if (val != null) entry.Setter(val);
@ -322,6 +652,45 @@ public class SettingsService : ISettingsService
{
MigrateSchema();
var settings = _db.GetCollection<SettingEntity>("settings");
var globalEntries = new (string Key, string Default)[]
{
(KeyOllamaUrl, _ollamaConfig.OllamaUrl),
(KeyLlamaCppUrl, _ollamaConfig.LlamaCppUrl),
(KeyBackend, _ollamaConfig.Backend),
(KeyRagModel, _ollamaConfig.EmbedModel),
(KeyStyleModel, _ollamaConfig.StyleModel),
(KeySpellingModel, _ollamaConfig.SpellingModel),
};
foreach (var entry in globalEntries)
{
var existing = settings.FindOne(s =>
s.Key == entry.Key && s.UserId == null && s.ProjectId == null);
if (existing != null)
{
var val = existing.Value;
if (entry.Key == KeyOllamaUrl) _ollamaConfig.OllamaUrl = val;
else if (entry.Key == KeyLlamaCppUrl) _ollamaConfig.LlamaCppUrl = val;
else if (entry.Key == KeyBackend) _ollamaConfig.Backend = val;
else if (entry.Key == KeyRagModel) _ollamaConfig.EmbedModel = val;
else if (entry.Key == KeyStyleModel) _ollamaConfig.StyleModel = val;
else if (entry.Key == KeySpellingModel) _ollamaConfig.SpellingModel = val;
}
else
{
settings.Insert(new SettingEntity
{
Key = entry.Key,
Value = entry.Default,
UserId = null,
ProjectId = null
});
}
}
if (_currentUserId == null)
{
var firstUser = _db.GetCollection<UserEntity>("users").FindAll().FirstOrDefault();
@ -329,16 +698,10 @@ public class SettingsService : ISettingsService
_currentUserId = firstUser.Id;
}
var settings = _db.GetCollection<SettingEntity>("settings");
var lang = _ollamaConfig.Language;
var entries = new (string Key, string Default)[]
var userEntries = new (string Key, string Default)[]
{
(KeyOllamaUrl, _ollamaConfig.OllamaUrl),
(KeyRagModel, _ollamaConfig.StyleModel),
(KeyStyleModel, _ollamaConfig.StyleModel),
(KeySpellingModel, _ollamaConfig.SpellingModel),
(KeyTheme, _ollamaConfig.Theme),
(KeyLanguage, lang),
(KeyLektorPrompt, lang == "en" ? _ollamaConfig.LektorSystemPromptEn : _ollamaConfig.LektorSystemPrompt),
@ -347,9 +710,31 @@ public class SettingsService : ISettingsService
(KeyAdvocatusDiaboli, lang == "en" ? _ollamaConfig.AdvocatusDiaboliPromptEn : _ollamaConfig.AdvocatusDiaboliPrompt),
(KeyHookFinder, lang == "en" ? _ollamaConfig.HookFinderPromptEn : _ollamaConfig.HookFinderPrompt),
(KeyEli5, lang == "en" ? _ollamaConfig.Eli5PromptEn : _ollamaConfig.Eli5Prompt),
(KeyCoAuthor, lang == "en" ? _ollamaConfig.CoAuthorBrainstormingPromptEn : _ollamaConfig.CoAuthorBrainstormingPrompt),
(KeyTechLastenheft, lang == "en" ? _ollamaConfig.TechLastenheftPromptEn : _ollamaConfig.TechLastenheftPrompt),
(KeyTechPflichtenheft, lang == "en" ? _ollamaConfig.TechPflichtenheftPromptEn : _ollamaConfig.TechPflichtenheftPrompt),
(KeyTechUseCase, lang == "en" ? _ollamaConfig.TechUseCasePromptEn : _ollamaConfig.TechUseCasePrompt),
(KeyTechStakeholder, lang == "en" ? _ollamaConfig.TechStakeholderPromptEn : _ollamaConfig.TechStakeholderPrompt),
(KeyTechGlossar, lang == "en" ? _ollamaConfig.TechGlossarPromptEn : _ollamaConfig.TechGlossarPrompt),
(KeyTechQualitaet, lang == "en" ? _ollamaConfig.TechQualitaetPromptEn : _ollamaConfig.TechQualitaetPrompt),
(KeyTechRisiko, lang == "en" ? _ollamaConfig.TechRisikoPromptEn : _ollamaConfig.TechRisikoPrompt),
(KeyLektorTemp, _ollamaConfig.LektorTemp.ToString("F1")),
(KeyFactCheckTemp, _ollamaConfig.FactCheckTemp.ToString("F1")),
(KeySpellingTemp, _ollamaConfig.SpellingTemp.ToString("F1")),
(KeyAdvocatusDiaboliTemp, _ollamaConfig.AdvocatusDiaboliTemp.ToString("F1")),
(KeyHookFinderTemp, _ollamaConfig.HookFinderTemp.ToString("F1")),
(KeyEli5Temp, _ollamaConfig.Eli5Temp.ToString("F1")),
(KeyCoAuthorTemp, _ollamaConfig.CoAuthorTemp.ToString("F1")),
(KeyTechLastenheftTemp, _ollamaConfig.TechLastenheftTemp.ToString("F1")),
(KeyTechPflichtenheftTemp, _ollamaConfig.TechPflichtenheftTemp.ToString("F1")),
(KeyTechUseCaseTemp, _ollamaConfig.TechUseCaseTemp.ToString("F1")),
(KeyTechStakeholderTemp, _ollamaConfig.TechStakeholderTemp.ToString("F1")),
(KeyTechGlossarTemp, _ollamaConfig.TechGlossarTemp.ToString("F1")),
(KeyTechQualitaetTemp, _ollamaConfig.TechQualitaetTemp.ToString("F1")),
(KeyTechRisikoTemp, _ollamaConfig.TechRisikoTemp.ToString("F1")),
};
foreach (var entry in entries)
foreach (var entry in userEntries)
{
var existing = settings.FindOne(s =>
s.Key == entry.Key && s.UserId == _currentUserId && s.ProjectId == _currentProjectId);
@ -357,11 +742,7 @@ public class SettingsService : ISettingsService
if (existing != null)
{
var val = existing.Value;
if (entry.Key == KeyOllamaUrl) _ollamaConfig.OllamaUrl = val;
else if (entry.Key == KeyRagModel) _ollamaConfig.EmbedModel = val;
else if (entry.Key == KeyStyleModel) _ollamaConfig.StyleModel = val;
else if (entry.Key == KeySpellingModel) _ollamaConfig.SpellingModel = val;
else if (entry.Key == KeyTheme) _ollamaConfig.Theme = val;
if (entry.Key == KeyTheme) _ollamaConfig.Theme = val;
else if (entry.Key == KeyLanguage) _ollamaConfig.Language = val;
else if (entry.Key == KeyLektorPrompt) _ollamaConfig.LektorSystemPrompt = val;
else if (entry.Key == KeyFactCheckTemplate) _ollamaConfig.FactCheckTemplate = val;
@ -369,6 +750,28 @@ public class SettingsService : ISettingsService
else if (entry.Key == KeyAdvocatusDiaboli) _ollamaConfig.AdvocatusDiaboliPrompt = val;
else if (entry.Key == KeyHookFinder) _ollamaConfig.HookFinderPrompt = val;
else if (entry.Key == KeyEli5) _ollamaConfig.Eli5Prompt = val;
else if (entry.Key == KeyCoAuthor) _ollamaConfig.CoAuthorBrainstormingPrompt = val;
else if (entry.Key == KeyLektorTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.LektorTemp = t; }
else if (entry.Key == KeyFactCheckTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.FactCheckTemp = t; }
else if (entry.Key == KeySpellingTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.SpellingTemp = t; }
else if (entry.Key == KeyAdvocatusDiaboliTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.AdvocatusDiaboliTemp = t; }
else if (entry.Key == KeyHookFinderTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.HookFinderTemp = t; }
else if (entry.Key == KeyEli5Temp) { if (double.TryParse(val, out var t)) _ollamaConfig.Eli5Temp = t; }
else if (entry.Key == KeyCoAuthorTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.CoAuthorTemp = t; }
else if (entry.Key == KeyTechLastenheft) _ollamaConfig.TechLastenheftPrompt = val;
else if (entry.Key == KeyTechPflichtenheft) _ollamaConfig.TechPflichtenheftPrompt = val;
else if (entry.Key == KeyTechUseCase) _ollamaConfig.TechUseCasePrompt = val;
else if (entry.Key == KeyTechStakeholder) _ollamaConfig.TechStakeholderPrompt = val;
else if (entry.Key == KeyTechGlossar) _ollamaConfig.TechGlossarPrompt = val;
else if (entry.Key == KeyTechQualitaet) _ollamaConfig.TechQualitaetPrompt = val;
else if (entry.Key == KeyTechRisiko) _ollamaConfig.TechRisikoPrompt = val;
else if (entry.Key == KeyTechLastenheftTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechLastenheftTemp = t; }
else if (entry.Key == KeyTechPflichtenheftTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechPflichtenheftTemp = t; }
else if (entry.Key == KeyTechUseCaseTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechUseCaseTemp = t; }
else if (entry.Key == KeyTechStakeholderTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechStakeholderTemp = t; }
else if (entry.Key == KeyTechGlossarTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechGlossarTemp = t; }
else if (entry.Key == KeyTechQualitaetTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechQualitaetTemp = t; }
else if (entry.Key == KeyTechRisikoTemp) { if (double.TryParse(val, out var t)) _ollamaConfig.TechRisikoTemp = t; }
}
else
{

0
err.txt Normal file
View file

29
ollama_config.json Normal file
View file

@ -0,0 +1,29 @@
{
"StyleModel": "gemma4:e4b",
"EmbedModel": "nomic-embed-text",
"SpellingModel": "phi3:mini",
"OllamaUrl": "http://localhost:11434",
"Theme": "light",
"Language": "de",
"LektorSystemPrompt": "Du bist ein erfahrener Lektor. Analysiere den Text des Autors. Optimiere ihn auf einen lebendigen, anschaulichen und subjektiven Stil. Nutze starke Verben, vermeide unn\u00F6tige Adjektive und achte auf \u0027Show, don\u0027t tell\u0027. Antworte direkt mit dem verbesserten Text oder konkreten Vorschl\u00E4gen.",
"FactCheckTemplate": "FAKTEN AUS DER DATENBANK:\n{0}\n\nFRAGE / KONTEXT:\n{1}\n\nBasierend auf den Fakten, schreibe eine kurze, lebendige Erg\u00E4nzung oder Korrektur.",
"SpellingPrompt": "Du bist ein Rechtschreibpr\u00FCfer. Korrigiere NUR Fehler (Rechtschreibung/Grammatik). \u00C4ndere den Stil nicht. Gib nur den korrigierten Text zur\u00FCck.",
"AdvocatusDiaboliPrompt": "Nimm eine radikale Gegenposition zu meinem Text ein. Welche Schwachstellen in meiner Argumentation fallen dir auf?",
"HookFinderPrompt": "Generiere mir 5 emotionale, packende Einstiegss\u00E4tze (Hooks) f\u00FCr diesen Text, die den Leser sofort fesseln.",
"Eli5Prompt": "Wo ist der Text zu kompliziert geschrieben? Liefere mir konkrete Vorschl\u00E4ge, wie ich komplexe Abs\u00E4tze einfacher formulieren kann, ohne den Kern zu verlieren.",
"CoAuthorBrainstormingPrompt": "Du bist ein kreativer Co-Autor. Der Nutzer gibt dir eine Idee, einen Charakter oder eine Situation. Hilf ihm, die Handlung oder Personen weiterzuentwickeln. Stelle kluge Fragen, schlage alternative Wendungen vor, denke \u00FCber Motivationen, Konflikte und emotionale Tiefe nach. Sei inspirierend, aber bleib beim Thema.",
"LektorSystemPromptEn": "You are an experienced editor. Analyze the author\u0027s text. Optimize it for a vivid, descriptive, and subjective style. Use strong verbs, avoid unnecessary adjectives, and follow \u0027Show, don\u0027t tell\u0027. Respond directly with the improved text or concrete suggestions.",
"FactCheckTemplateEn": "FACTS FROM DATABASE:\n{0}\n\nQUESTION / CONTEXT:\n{1}\n\nBased on the facts, write a short, vivid addition or correction.",
"SpellingPromptEn": "You are a spell checker. Correct ONLY errors (spelling/grammar). Do not change the style. Return only the corrected text.",
"AdvocatusDiaboliPromptEn": "Take a radical opposing position to my text. What weaknesses in my argumentation do you notice?",
"HookFinderPromptEn": "Generate 5 emotional, captivating opening sentences (hooks) for this text that immediately grab the reader\u0027s attention.",
"Eli5PromptEn": "Where is the text too complicated? Give me concrete suggestions on how to simplify complex paragraphs without losing the core message.",
"CoAuthorBrainstormingPromptEn": "You are a creative co-author. The user gives you an idea, a character, or a situation. Help them develop the plot or characters further. Ask smart questions, suggest alternative twists, think about motivations, conflicts, and emotional depth. Be inspiring but stay on topic.",
"LektorTemp": 0.7,
"FactCheckTemp": 0.3,
"SpellingTemp": 0.1,
"AdvocatusDiaboliTemp": 0.9,
"HookFinderTemp": 0.9,
"Eli5Temp": 0.5,
"CoAuthorTemp": 1
}

10
out.txt Normal file
View file

@ -0,0 +1,10 @@
Die Starteinstellungen von E:\ollama_net\ollama\AuthorBuddy.Web\Properties\launchSettings.json werden verwendet…
Buildvorgang wird ausgeführt...
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5216
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: E:\ollama_net\ollama\AuthorBuddy.Web

View file

@ -176,6 +176,17 @@ body {
height: 12px;
}
.status-bar .status-thought {
color: var(--text-color);
opacity: 0.7;
font-style: italic;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
/* Main content area */
.main-content {
display: flex;
@ -307,6 +318,49 @@ body {
line-height: 1.6;
}
.editor-format-toolbar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 2px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.fmt-btn {
min-width: 32px;
height: 28px;
padding: 0 6px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: var(--text-color);
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fmt-btn:hover {
background: var(--menu-bg);
border-color: var(--border-color);
}
.fmt-btn:active {
background: var(--accent-color);
color: white;
}
.fmt-sep {
width: 1px;
height: 20px;
background: var(--border-color);
margin: 0 6px;
flex-shrink: 0;
}
.editor-toolbar {
display: flex;
justify-content: flex-end;
@ -530,6 +584,29 @@ button.primary {
font-size: 13px;
}
.template-radio-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.template-radio {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.template-radio input[type="radio"] {
width: auto;
margin: 0;
}
/* File tree in sidebar */
.tree-item {
display: flex;
@ -573,6 +650,15 @@ button.primary {
flex: 1;
}
.tree-checkbox {
margin: 0;
width: 14px;
height: 14px;
cursor: pointer;
flex-shrink: 0;
accent-color: var(--accent-color);
}
.tree-rename {
background: none;
border: none;
@ -711,6 +797,57 @@ button.primary {
font-size: 13px;
}
.input-with-button {
display: flex;
gap: 8px;
align-items: center;
}
.input-with-button input {
flex: 1;
}
.ollama-status {
margin-top: 4px;
font-size: 12px;
font-weight: bold;
}
.temp-slider {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
}
.temp-label {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 4px;
}
.temp-value {
font-weight: bold;
color: var(--accent-color);
}
.temp-slider input[type="range"] {
width: 100%;
margin: 4px 0;
}
.temp-category {
font-size: 11px;
font-weight: bold;
margin-top: 2px;
}
.temp-desc {
font-size: 11px;
color: var(--text-muted, #888);
margin-top: 1px;
}
/* Diff view */
.diff-panel {
border-top: 2px solid var(--border-color);
@ -864,6 +1001,41 @@ button.primary {
font-style: italic;
}
.assistant-prompt-editor {
padding: 8px 12px;
margin: 6px 0;
border: 1px solid var(--border-color);
background: var(--card-bg);
border-radius: 4px;
}
.assistant-prompt-textarea {
width: 100%;
resize: vertical;
padding: 6px 8px;
border: 1px solid var(--border-color);
background: var(--input-bg);
color: var(--text-color);
font-family: monospace;
font-size: 12px;
box-sizing: border-box;
}
.btn-small {
padding: 2px 10px;
font-size: 12px;
border: 1px solid var(--border-color);
background: var(--menu-bg);
color: var(--text-color);
cursor: pointer;
border-radius: 3px;
}
.btn-small:hover {
background: var(--accent-color);
color: #fff;
}
::-webkit-scrollbar-track {
background: var(--window-bg);
}
@ -999,6 +1171,13 @@ button.primary {
flex: 1;
}
.sidebar-bottom-btns {
display: flex;
justify-content: center;
gap: 4px;
padding: 8px 0;
}
.sidebar-info-btn {
width: 28px;
height: 28px;
@ -1012,7 +1191,6 @@ button.primary {
display: flex;
align-items: center;
justify-content: center;
margin: 8px auto;
padding: 0;
}
@ -1153,3 +1331,73 @@ button.primary {
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
font-size: 13px;
}
/* DB Admin page */
.dbadmin-page {
padding: 16px;
max-width: 1200px;
margin: 0 auto;
}
.dbadmin-toolbar {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.dbadmin-toolbar select {
padding: 6px 8px;
border: 1px solid var(--border-color);
background: var(--editor-bg);
color: var(--text-color);
font-size: 13px;
}
.dbadmin-table-wrapper {
overflow-x: auto;
}
.dbadmin-table {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 12px;
}
.dbadmin-table th,
.dbadmin-table td {
text-align: left;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
}
.dbadmin-table th {
font-weight: bold;
background: var(--menu-bg);
position: sticky;
top: 0;
}
.dbadmin-cell {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
.dbadmin-actions {
display: flex;
gap: 4px;
}
.dbadmin-status {
margin-top: 12px;
padding: 8px;
border-radius: 3px;
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
font-size: 13px;
}
.modal-dialog.modal-sm {
max-width: 400px;
}

14
wwwroot/js/download.js Normal file
View file

@ -0,0 +1,14 @@
window.downloadFile = function (fileName, base64, mimeType) {
const byteChars = atob(base64);
const bytes = new Uint8Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i);
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
};

View file

@ -0,0 +1,74 @@
window.EditorToolbar = {
wrapSelection: function (prefix, suffix) {
const t = document.querySelector('.editor-area');
if (!t) return;
const start = t.selectionStart, end = t.selectionEnd;
const val = t.value, sel = val.substring(start, end);
t.value = val.substring(0, start) + prefix + sel + suffix + val.substring(end);
const p = start + prefix.length;
t.selectionStart = p;
t.selectionEnd = p + sel.length;
t.dispatchEvent(new Event('input', { bubbles: true }));
},
insertHeading: function (level) {
const t = document.querySelector('.editor-area');
if (!t) return;
const start = t.selectionStart;
const val = t.value;
let lineStart = start;
while (lineStart > 0 && val[lineStart - 1] !== '\n') lineStart--;
const prefix = '#'.repeat(level) + ' ';
t.value = val.substring(0, lineStart) + prefix + val.substring(lineStart);
const pos = lineStart + prefix.length + (start - lineStart);
t.selectionStart = pos;
t.selectionEnd = pos;
t.dispatchEvent(new Event('input', { bubbles: true }));
},
insertList: function (ordered) {
const t = document.querySelector('.editor-area');
if (!t) return;
const start = t.selectionStart, end = t.selectionEnd;
const val = t.value;
let sel = val.substring(start, end);
if (sel.trim() === '') {
sel = ordered
? '1. Erstes Element\n2. Zweites Element\n3. Drittes Element'
: '- Erstes Element\n- Zweites Element\n- Drittes Element';
} else {
const lines = sel.split('\n');
sel = ordered
? lines.map((l, i) => `${i + 1}. ${l}`).join('\n')
: lines.map(l => `- ${l}`).join('\n');
}
t.value = val.substring(0, start) + sel + val.substring(end);
t.selectionStart = start;
t.selectionEnd = start + sel.length;
t.dispatchEvent(new Event('input', { bubbles: true }));
},
indentLines: function () {
const t = document.querySelector('.editor-area');
if (!t) return;
const start = t.selectionStart, end = t.selectionEnd;
const val = t.value;
let sel = val.substring(start, end);
if (sel === '') return;
sel = sel.replace(/^/gm, ' ');
t.value = val.substring(0, start) + sel + val.substring(end);
t.selectionStart = start;
t.selectionEnd = start + sel.length;
t.dispatchEvent(new Event('input', { bubbles: true }));
},
outdentLines: function () {
const t = document.querySelector('.editor-area');
if (!t) return;
const start = t.selectionStart, end = t.selectionEnd;
const val = t.value;
let sel = val.substring(start, end);
if (sel === '') return;
sel = sel.replace(/^ {1,4}/gm, '');
t.value = val.substring(0, start) + sel + val.substring(end);
t.selectionStart = start;
t.selectionEnd = start + sel.length;
t.dispatchEvent(new Event('input', { bubbles: true }));
}
};