@bibobonking/opensol 3.1.4 → 3.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -12
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/dist/assets/index-C0yvs6Vl.js +281 -0
- package/dist/assets/index-CxNPLBg7.css +1 -0
- package/dist/favicon.svg +18 -15
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/public/favicon.svg +18 -15
- package/server/daemon.mjs +66 -49
- package/server/lib/deploy.mjs +222 -121
- package/server/lib/providers.mjs +23 -1
- package/server/lib/skills.mjs +385 -100
- package/server/lib/tools.mjs +0 -40
- package/src/agent/adapters/openai-responses.ts +1 -5
- package/src/agent/adapters/openai.ts +4 -11
- package/src/agent/prompt.ts +7 -14
- package/src/agent/subagent.ts +3 -14
- package/src/agent/tools.ts +2 -0
- package/src/components/ChatView.tsx +2 -1
- package/src/components/Composer.tsx +2 -1
- package/src/components/ContextBar.tsx +10 -0
- package/src/components/DeployModal.tsx +266 -252
- package/src/components/FileIcon.tsx +391 -99
- package/src/components/HomeDashboard.tsx +5 -10
- package/src/components/Logo.tsx +9 -11
- package/src/components/ModelDeck.tsx +8 -11
- package/src/components/SettingsModal.tsx +160 -122
- package/src/components/Sidebar.tsx +4 -3
- package/src/components/SystemPromptButton.tsx +198 -0
- package/src/components/ToolCallCard.tsx +2 -0
- package/src/components/TopBar.tsx +2 -19
- package/src/components/WorkspacePanel.tsx +76 -93
- package/src/core/plugins/tools/skill.ts +19 -76
- package/src/core/services/agent-loop.ts +16 -31
- package/src/core/services/subagents.ts +2 -2
- package/src/core/services/system-prompt.ts +16 -23
- package/src/core/services/tools.ts +0 -1
- package/src/lib/boot.ts +2 -0
- package/src/lib/credits.ts +33 -0
- package/src/lib/daemon.ts +40 -19
- package/src/lib/permissions.ts +1 -1
- package/src/lib/retry.test.mjs +40 -12
- package/src/lib/retry.ts +41 -8
- package/src/lib/system-prompt.ts +23 -0
- package/src/lib/usage.ts +8 -13
- package/src/store/chats.ts +0 -1
- package/src/store/settings.ts +12 -44
- package/src/store/skills.ts +79 -0
- package/src/store/ui.ts +3 -1
- package/src/types.ts +30 -15
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-B1pQC9Fe.js +0 -288
- package/dist/assets/index-qLr4aZVC.css +0 -1
- package/src/components/SystemPromptModal.tsx +0 -168
- package/src/lib/skills.test.mjs +0 -22
- package/src/lib/skills.ts +0 -93
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ChevronDown,
|
|
7
7
|
Cloud,
|
|
8
8
|
Cpu,
|
|
9
|
+
Download,
|
|
9
10
|
Eye,
|
|
10
11
|
EyeOff,
|
|
11
12
|
FilePenLine,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
FileSearch,
|
|
14
15
|
FileText,
|
|
15
16
|
FolderTree,
|
|
17
|
+
FolderUp,
|
|
16
18
|
GitBranch,
|
|
17
19
|
Globe2,
|
|
18
20
|
HardDrive,
|
|
@@ -38,9 +40,10 @@ import {
|
|
|
38
40
|
Wrench,
|
|
39
41
|
X,
|
|
40
42
|
} from 'lucide-react'
|
|
41
|
-
import type { DeployProviderId, Effort, ModelInfo, ProviderId, SearchProvider,
|
|
43
|
+
import type { DeployProviderId, Effort, InstalledSkill, ModelInfo, ProviderId, SearchProvider, BuiltinToolName } from '../types'
|
|
42
44
|
import type { LucideIcon } from 'lucide-react'
|
|
43
45
|
import { EFFORT_LABELS, PROVIDERS, PROVIDER_MAP } from '../data/providers'
|
|
46
|
+
import { useSkills } from '../store/skills'
|
|
44
47
|
import {
|
|
45
48
|
DEFAULT_SYSTEM_PROMPT,
|
|
46
49
|
activeModelId,
|
|
@@ -53,8 +56,7 @@ import { useChats, exportChats, importChats } from '../store/chats'
|
|
|
53
56
|
import { useWorkspace } from '../store/workspace'
|
|
54
57
|
import { stopAllRuns } from '../agent'
|
|
55
58
|
import { applySnapshot } from '../lib/boot'
|
|
56
|
-
import { executeDaemonTool,
|
|
57
|
-
import { skillFromFolder } from '../lib/skills'
|
|
59
|
+
import { executeDaemonTool, resetScope } from '../lib/daemon'
|
|
58
60
|
import { effortOptions, reasoningSummary } from '../lib/reasoning'
|
|
59
61
|
import { PERMISSION_MODES, permissionMode, permissionTools } from '../lib/permissions'
|
|
60
62
|
import { cn, formatBytes } from '../lib/utils'
|
|
@@ -64,18 +66,19 @@ import { useMcp } from '../store/mcp'
|
|
|
64
66
|
import { pluginRuntime } from '../plugins'
|
|
65
67
|
import { Modal, Segmented, Spinner, Switch } from './ui'
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
type TabDef = { id: string; label: string; icon: typeof Info; description: string; disabled?: boolean }
|
|
70
|
+
const TABS: readonly TabDef[] = [
|
|
68
71
|
{ id: 'general', label: 'General', icon: SlidersHorizontal, description: 'Permissions, appearance and conversation display.' },
|
|
69
72
|
{ id: 'models', label: 'Models', icon: Cpu, description: 'Providers, encrypted keys, live models and reasoning defaults.' },
|
|
70
73
|
{ id: 'tools', label: 'Tools', icon: Wrench, description: 'What OpenSol can do on this machine, and when it must ask.' },
|
|
71
74
|
{ id: 'search', label: 'Search', icon: Globe2, description: 'Give the agent current web results with your own key.' },
|
|
72
|
-
{ id: 'skills', label: 'Skills', icon: Sparkles, description: '
|
|
73
|
-
{ id: 'plugins', label: 'Plugins', icon: Package, description: '
|
|
75
|
+
{ id: 'skills', label: 'Skills', icon: Sparkles, description: 'Skill folders the agent loads on demand: upload a folder or install from npm.' },
|
|
76
|
+
{ id: 'plugins', label: 'Plugins', icon: Package, description: 'Plugins are temporarily disabled for maintenance.', disabled: true },
|
|
74
77
|
{ id: 'mcp', label: 'MCP', icon: Link2, description: 'Model Context Protocol servers now have their own panel, with OAuth sign-in and local, HTTP and SSE connections.' },
|
|
75
78
|
{ id: 'deploy', label: 'Deploy', icon: Rocket, description: 'Hosting credentials and command-line tools.' },
|
|
76
79
|
{ id: 'appearance', label: 'Appearance', icon: Palette, description: 'Theme and interface preferences.' },
|
|
77
80
|
{ id: 'about', label: 'About', icon: Info, description: 'Local storage, history and reset controls.' },
|
|
78
|
-
]
|
|
81
|
+
]
|
|
79
82
|
|
|
80
83
|
const fieldClass =
|
|
81
84
|
'h-8 w-full rounded-lg border border-line bg-panel px-2.5 text-[12px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-50'
|
|
@@ -630,7 +633,7 @@ function ModelsTab() {
|
|
|
630
633
|
className="thin-scroll w-full resize-y rounded-lg border border-line bg-panel px-2.5 py-2 font-mono text-[10.5px] leading-relaxed text-ink outline-none focus:border-accent/50"
|
|
631
634
|
/>
|
|
632
635
|
<p className="mt-1 text-[9px] leading-relaxed text-faint">
|
|
633
|
-
|
|
636
|
+
The installed skill list and the current workspace/git context are appended after this prompt.
|
|
634
637
|
</p>
|
|
635
638
|
</div>
|
|
636
639
|
</div>
|
|
@@ -676,7 +679,7 @@ const TOOL_COPY: Record<BuiltinToolName, { label: string; description: string }>
|
|
|
676
679
|
read_url: { label: 'Read URL', description: 'Open a public http(s) page as plain text.' },
|
|
677
680
|
git: { label: 'Git', description: 'Inspect or change repository state. Plan mode is read-only.' },
|
|
678
681
|
subagent: { label: 'Subagent', description: 'Delegate a read-only investigation and return a concise report.' },
|
|
679
|
-
skill: { label: 'Skill', description: '
|
|
682
|
+
skill: { label: 'Skill', description: 'Load an installed skill (SKILL.md and its bundled files) when a task matches it.' },
|
|
680
683
|
}
|
|
681
684
|
|
|
682
685
|
function ToolsTab() {
|
|
@@ -972,165 +975,202 @@ function SearchTab() {
|
|
|
972
975
|
)
|
|
973
976
|
}
|
|
974
977
|
|
|
978
|
+
interface PickedFile {
|
|
979
|
+
path: string
|
|
980
|
+
file: File
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const SKIP_PATH = /(^|\/)(node_modules|\.git|__pycache__)(\/|$)/
|
|
984
|
+
const MAX_SKILL_FILE = 1024 * 1024
|
|
985
|
+
|
|
986
|
+
async function fileToBase64(file: File): Promise<string> {
|
|
987
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
988
|
+
let binary = ''
|
|
989
|
+
const chunk = 0x8000
|
|
990
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
991
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk))
|
|
992
|
+
}
|
|
993
|
+
return btoa(binary)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function formatSkillSource(source: string) {
|
|
997
|
+
if (source.startsWith('npm:')) return source
|
|
998
|
+
if (source === 'upload') return 'Folder'
|
|
999
|
+
if (source === 'legacy') return 'Migrated'
|
|
1000
|
+
return 'Local'
|
|
1001
|
+
}
|
|
1002
|
+
|
|
975
1003
|
function SkillsTab() {
|
|
976
|
-
const
|
|
977
|
-
const
|
|
978
|
-
const
|
|
979
|
-
const
|
|
1004
|
+
const skills = useSkills((state) => state.skills)
|
|
1005
|
+
const loading = useSkills((state) => state.loading)
|
|
1006
|
+
const error = useSkills((state) => state.error)
|
|
1007
|
+
const load = useSkills((state) => state.load)
|
|
1008
|
+
const upload = useSkills((state) => state.upload)
|
|
1009
|
+
const installNpm = useSkills((state) => state.installNpm)
|
|
1010
|
+
const setEnabled = useSkills((state) => state.setEnabled)
|
|
1011
|
+
const remove = useSkills((state) => state.remove)
|
|
980
1012
|
const askConfirm = useUi((state) => state.askConfirm)
|
|
981
1013
|
const notify = useUi((state) => state.notify)
|
|
982
|
-
const [installing, setInstalling] = useState('')
|
|
983
|
-
const [npmName, setNpmName] = useState('')
|
|
984
1014
|
const folderRef = useRef<HTMLInputElement>(null)
|
|
985
|
-
const
|
|
1015
|
+
const [busy, setBusy] = useState<'folder' | 'npm' | null>(null)
|
|
1016
|
+
const [npmSpec, setNpmSpec] = useState('')
|
|
1017
|
+
const active = skills.filter((skill) => skill.enabled).length
|
|
986
1018
|
|
|
987
1019
|
useEffect(() => {
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
input.setAttribute('webkitdirectory', '')
|
|
991
|
-
input.setAttribute('directory', '')
|
|
992
|
-
input.setAttribute('multiple', '')
|
|
993
|
-
}, [])
|
|
994
|
-
|
|
995
|
-
const store = (skill: Skill, verb: string) => {
|
|
996
|
-
const exists = useSettings.getState().skills.some((entry) => entry.id === skill.id)
|
|
997
|
-
if (exists) updateSkill(skill.id, skill)
|
|
998
|
-
else addSkill(skill)
|
|
999
|
-
notify(`${skill.name} ${verb}.`, 'success')
|
|
1000
|
-
}
|
|
1020
|
+
void load()
|
|
1021
|
+
}, [load])
|
|
1001
1022
|
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1023
|
+
const uploadFolder = async (list: FileList) => {
|
|
1024
|
+
const picked: PickedFile[] = Array.from(list)
|
|
1025
|
+
.map((file) => ({ file, path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name }))
|
|
1026
|
+
.filter((item) => !SKIP_PATH.test(item.path))
|
|
1027
|
+
if (picked.length === 0) return
|
|
1028
|
+
const tooBig = picked.filter((item) => item.file.size > MAX_SKILL_FILE)
|
|
1029
|
+
const usable = picked.filter((item) => item.file.size <= MAX_SKILL_FILE)
|
|
1030
|
+
if (!usable.some((item) => /(^|\/)skill\.md$/i.test(item.path))) {
|
|
1031
|
+
notify('That folder has no SKILL.md. Choose the skill folder itself (or a folder that contains skill folders).', 'error')
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
setBusy('folder')
|
|
1005
1035
|
try {
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1036
|
+
const files = await Promise.all(usable.map(async (item) => ({ path: item.path, data: await fileToBase64(item.file) })))
|
|
1037
|
+
const installed = await upload(files)
|
|
1038
|
+
notify(
|
|
1039
|
+
`Installed ${installed.join(', ')}${tooBig.length > 0 ? ` · skipped ${tooBig.length} file${tooBig.length === 1 ? '' : 's'} over 1 MB` : ''}.`,
|
|
1040
|
+
'success',
|
|
1041
|
+
)
|
|
1008
1042
|
} catch (cause) {
|
|
1009
|
-
notify(cause instanceof Error ? cause.message : 'Could not
|
|
1043
|
+
notify(cause instanceof Error ? cause.message : 'Could not install that skill folder.', 'error')
|
|
1010
1044
|
} finally {
|
|
1011
|
-
|
|
1045
|
+
setBusy(null)
|
|
1012
1046
|
}
|
|
1013
1047
|
}
|
|
1014
1048
|
|
|
1015
|
-
const
|
|
1016
|
-
const
|
|
1017
|
-
if (!
|
|
1018
|
-
|
|
1049
|
+
const installFromNpm = async () => {
|
|
1050
|
+
const spec = npmSpec.trim()
|
|
1051
|
+
if (!spec) return
|
|
1052
|
+
setBusy('npm')
|
|
1019
1053
|
try {
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1054
|
+
const installed = await installNpm(spec)
|
|
1055
|
+
setNpmSpec('')
|
|
1056
|
+
notify(`Installed ${installed.join(', ')} from npm.`, 'success')
|
|
1023
1057
|
} catch (cause) {
|
|
1024
1058
|
notify(cause instanceof Error ? cause.message : 'Could not install that npm package.', 'error')
|
|
1025
1059
|
} finally {
|
|
1026
|
-
|
|
1060
|
+
setBusy(null)
|
|
1027
1061
|
}
|
|
1028
1062
|
}
|
|
1029
1063
|
|
|
1030
|
-
const
|
|
1064
|
+
const askRemove = async (skill: InstalledSkill) => {
|
|
1031
1065
|
const confirmed = await askConfirm({
|
|
1032
1066
|
title: 'Delete this skill?',
|
|
1033
|
-
body: `“${skill.name}” and its
|
|
1067
|
+
body: `“${skill.name}” and its folder will be removed from this computer.`,
|
|
1034
1068
|
confirmLabel: 'Delete skill',
|
|
1035
1069
|
tone: 'danger',
|
|
1036
1070
|
})
|
|
1037
|
-
if (confirmed)
|
|
1071
|
+
if (!confirmed) return
|
|
1072
|
+
try {
|
|
1073
|
+
await remove(skill.name)
|
|
1074
|
+
} catch (cause) {
|
|
1075
|
+
notify(cause instanceof Error ? cause.message : 'Could not delete the skill.', 'error')
|
|
1076
|
+
}
|
|
1038
1077
|
}
|
|
1039
1078
|
|
|
1040
1079
|
return (
|
|
1041
1080
|
<SettingsSection
|
|
1042
1081
|
title="Installed skills"
|
|
1043
|
-
description={`${active} of ${
|
|
1082
|
+
description={`${active} of ${skills.length} enabled. The model sees each skill's name and description, then loads the full SKILL.md and bundled files on demand with the skill tool.`}
|
|
1044
1083
|
>
|
|
1045
|
-
<div className="mb-3
|
|
1046
|
-
<
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
<input
|
|
1056
|
-
ref={folderRef}
|
|
1057
|
-
type="file"
|
|
1058
|
-
className="hidden"
|
|
1059
|
-
onChange={(event) => {
|
|
1060
|
-
void installFolder(event.target.files)
|
|
1061
|
-
event.target.value = ''
|
|
1062
|
-
}}
|
|
1063
|
-
/>
|
|
1064
|
-
<div className="flex gap-1.5">
|
|
1084
|
+
<div className="mb-3 rounded-xl border border-line p-3">
|
|
1085
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1086
|
+
<button
|
|
1087
|
+
type="button"
|
|
1088
|
+
onClick={() => folderRef.current?.click()}
|
|
1089
|
+
disabled={busy !== null}
|
|
1090
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-ink px-2.5 text-[10.5px] font-medium text-panel disabled:opacity-50"
|
|
1091
|
+
>
|
|
1092
|
+
{busy === 'folder' ? <Spinner size={12} /> : <FolderUp size={12} />} Upload skill folder
|
|
1093
|
+
</button>
|
|
1065
1094
|
<input
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1095
|
+
ref={folderRef}
|
|
1096
|
+
type="file"
|
|
1097
|
+
className="hidden"
|
|
1098
|
+
multiple
|
|
1099
|
+
// @ts-expect-error webkitdirectory is a non-standard but widely supported attribute
|
|
1100
|
+
webkitdirectory=""
|
|
1101
|
+
directory=""
|
|
1102
|
+
onChange={(event) => {
|
|
1103
|
+
if (event.target.files) void uploadFolder(event.target.files)
|
|
1104
|
+
event.target.value = ''
|
|
1070
1105
|
}}
|
|
1071
|
-
placeholder="npm package, e.g. @scope/my-skill"
|
|
1072
|
-
spellCheck={false}
|
|
1073
|
-
className={cn(fieldClass, 'font-mono text-[10.5px]')}
|
|
1074
1106
|
/>
|
|
1107
|
+
<span className="text-[10px] text-faint">A folder with a SKILL.md, plus optional scripts/ and references/.</span>
|
|
1108
|
+
</div>
|
|
1109
|
+
<form
|
|
1110
|
+
className="mt-2.5 flex flex-wrap items-center gap-2"
|
|
1111
|
+
onSubmit={(event) => {
|
|
1112
|
+
event.preventDefault()
|
|
1113
|
+
void installFromNpm()
|
|
1114
|
+
}}
|
|
1115
|
+
>
|
|
1116
|
+
<div className="relative min-w-[220px] flex-1">
|
|
1117
|
+
<Package size={12} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-faint" />
|
|
1118
|
+
<input
|
|
1119
|
+
value={npmSpec}
|
|
1120
|
+
onChange={(event) => setNpmSpec(event.target.value)}
|
|
1121
|
+
placeholder="npm package, e.g. @scope/my-skills"
|
|
1122
|
+
className={cn(fieldClass, 'pl-7 font-mono text-[11px]')}
|
|
1123
|
+
aria-label="npm package"
|
|
1124
|
+
spellCheck={false}
|
|
1125
|
+
/>
|
|
1126
|
+
</div>
|
|
1075
1127
|
<button
|
|
1076
|
-
type="
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised disabled:opacity-40"
|
|
1128
|
+
type="submit"
|
|
1129
|
+
disabled={busy !== null || !npmSpec.trim()}
|
|
1130
|
+
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-2.5 text-[10.5px] font-medium text-ink hover:bg-raised disabled:opacity-50"
|
|
1080
1131
|
>
|
|
1081
|
-
{
|
|
1082
|
-
Install
|
|
1132
|
+
{busy === 'npm' ? <Spinner size={12} /> : <Download size={12} />} Install from npm
|
|
1083
1133
|
</button>
|
|
1084
|
-
</
|
|
1134
|
+
</form>
|
|
1135
|
+
<p className="mt-2 text-[9.5px] leading-relaxed text-faint">
|
|
1136
|
+
npm installs run with scripts disabled; OpenSol only copies the SKILL.md folders the package ships. Skill scripts run
|
|
1137
|
+
only when the model calls the shell tool, and that still asks for approval.
|
|
1138
|
+
</p>
|
|
1085
1139
|
</div>
|
|
1086
|
-
<p className="mb-3 text-[9.5px] leading-relaxed text-faint">
|
|
1087
|
-
A skill is a folder with a <span className="font-mono">SKILL.md</span> (name, description, instructions) plus any scripts it needs.
|
|
1088
|
-
Folder uploads are read in your browser; npm packages come from the public registry through the local daemon.
|
|
1089
|
-
</p>
|
|
1090
1140
|
|
|
1091
|
-
{
|
|
1141
|
+
{error && <p className="mb-2 text-[10.5px] text-danger">{error}</p>}
|
|
1142
|
+
|
|
1143
|
+
{skills.length === 0 ? (
|
|
1092
1144
|
<div className="rounded-xl border border-dashed border-line px-4 py-8 text-center">
|
|
1093
|
-
<Sparkles size={
|
|
1094
|
-
<p className="mt-2 text-[
|
|
1095
|
-
<p className="mt-1 text-[
|
|
1145
|
+
<Sparkles size={16} className="mx-auto text-faint" />
|
|
1146
|
+
<p className="mt-2 text-[12px] font-medium text-ink">{loading ? 'Loading skills…' : 'No skills installed'}</p>
|
|
1147
|
+
<p className="mt-1 text-[10.5px] text-muted">Upload a skill folder or install one from npm to give the agent new abilities.</p>
|
|
1096
1148
|
</div>
|
|
1097
1149
|
) : (
|
|
1098
1150
|
<div className="overflow-hidden rounded-xl border border-line">
|
|
1099
|
-
{
|
|
1100
|
-
<div key={skill.
|
|
1151
|
+
{skills.map((skill, index) => (
|
|
1152
|
+
<div key={skill.name} className={cn('px-3 py-3', index !== 0 && 'border-t border-line')}>
|
|
1101
1153
|
<div className="flex items-start gap-2.5">
|
|
1102
1154
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-accent-soft text-accent">
|
|
1103
1155
|
<Sparkles size={11} />
|
|
1104
1156
|
</span>
|
|
1105
1157
|
<div className="min-w-0 flex-1">
|
|
1106
1158
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
1107
|
-
<p className="text-[11.5px] font-medium text-ink">{skill.name}</p>
|
|
1108
|
-
<span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
{skill.version && <span className="font-mono text-[8.5px] text-faint">v{skill.version}</span>}
|
|
1112
|
-
<span className={cn('text-[8.5px]', skill.enabled ? 'text-success' : 'text-faint')}>
|
|
1113
|
-
{skill.enabled ? 'Enabled' : 'Off'}
|
|
1159
|
+
<p className="font-mono text-[11.5px] font-medium text-ink">{skill.name}</p>
|
|
1160
|
+
<span className="rounded bg-raised px-1.5 py-0.5 text-[8px] font-medium text-faint">{formatSkillSource(skill.source)}</span>
|
|
1161
|
+
<span className="text-[8.5px] text-faint">
|
|
1162
|
+
{skill.fileCount} file{skill.fileCount === 1 ? '' : 's'} · {formatBytes(skill.size)}
|
|
1114
1163
|
</span>
|
|
1115
1164
|
</div>
|
|
1116
|
-
<p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description}</p>
|
|
1117
|
-
<p className="mt-0.5 truncate font-mono text-[8.5px] text-faint" title={skill.
|
|
1118
|
-
{skill.
|
|
1165
|
+
<p className="mt-0.5 text-[9.5px] leading-relaxed text-muted">{skill.description || 'No description.'}</p>
|
|
1166
|
+
<p className="mt-0.5 truncate font-mono text-[8.5px] text-faint" title={skill.path}>
|
|
1167
|
+
{skill.path}
|
|
1119
1168
|
</p>
|
|
1120
|
-
<details className="group/skill mt-1">
|
|
1121
|
-
<summary className="inline-flex cursor-pointer list-none items-center gap-1 text-[9px] font-medium text-faint hover:text-muted [&::-webkit-details-marker]:hidden">
|
|
1122
|
-
View SKILL.md
|
|
1123
|
-
<ChevronDown size={9} className="transition-transform group-open/skill:rotate-180" />
|
|
1124
|
-
</summary>
|
|
1125
|
-
<pre className="thin-scroll mt-1.5 max-h-44 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-raised/40 p-2 font-mono text-[9.5px] leading-relaxed text-muted">
|
|
1126
|
-
{skill.instructions}
|
|
1127
|
-
</pre>
|
|
1128
|
-
</details>
|
|
1129
1169
|
</div>
|
|
1130
1170
|
<div className="flex shrink-0 items-center gap-1">
|
|
1131
1171
|
<button
|
|
1132
1172
|
type="button"
|
|
1133
|
-
onClick={() => void
|
|
1173
|
+
onClick={() => void askRemove(skill)}
|
|
1134
1174
|
className="icon-button h-7 w-7 hover:text-danger"
|
|
1135
1175
|
aria-label={`Delete ${skill.name}`}
|
|
1136
1176
|
title="Delete skill"
|
|
@@ -1139,7 +1179,7 @@ function SkillsTab() {
|
|
|
1139
1179
|
</button>
|
|
1140
1180
|
<Switch
|
|
1141
1181
|
checked={skill.enabled}
|
|
1142
|
-
onChange={(enabled) =>
|
|
1182
|
+
onChange={(enabled) => void setEnabled(skill.name, enabled)}
|
|
1143
1183
|
label={`Enable ${skill.name}`}
|
|
1144
1184
|
/>
|
|
1145
1185
|
</div>
|
|
@@ -1979,17 +2019,15 @@ export function SettingsModal() {
|
|
|
1979
2019
|
{TABS.map((item, index) => {
|
|
1980
2020
|
const Icon = item.icon
|
|
1981
2021
|
const active = tab === item.id
|
|
1982
|
-
const disabled = item.id === 'plugins'
|
|
1983
2022
|
return (
|
|
1984
2023
|
<button
|
|
1985
2024
|
key={item.id}
|
|
1986
2025
|
type="button"
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
title={
|
|
2026
|
+
onClick={() => openSettings(item.id as typeof tab)}
|
|
2027
|
+
disabled={item.disabled}
|
|
2028
|
+
title={item.description}
|
|
1990
2029
|
className={cn(
|
|
1991
|
-
'group/tab relative mb-[2px] flex h-10 w-full items-center gap-3 rounded-2xl px-3 text-left text-[12.5px] transition-colors duration-200',
|
|
1992
|
-
disabled && 'cursor-not-allowed opacity-40',
|
|
2030
|
+
'group/tab relative mb-[2px] flex h-10 w-full items-center gap-3 rounded-2xl px-3 text-left text-[12.5px] transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-45',
|
|
1993
2031
|
active ? 'font-semibold text-ink' : 'text-muted hover:text-ink',
|
|
1994
2032
|
)}
|
|
1995
2033
|
>
|
|
@@ -1998,7 +2036,6 @@ export function SettingsModal() {
|
|
|
1998
2036
|
</span>
|
|
1999
2037
|
<Icon size={14} className={cn('transition-transform duration-300 group-hover/tab:scale-110', active ? 'text-accent' : 'text-faint')} />
|
|
2000
2038
|
{item.label}
|
|
2001
|
-
{disabled && <span className="ml-auto text-[8px] text-faint">off</span>}
|
|
2002
2039
|
</button>
|
|
2003
2040
|
)
|
|
2004
2041
|
})}
|
|
@@ -2016,9 +2053,10 @@ export function SettingsModal() {
|
|
|
2016
2053
|
<button
|
|
2017
2054
|
key={item.id}
|
|
2018
2055
|
type="button"
|
|
2019
|
-
onClick={() => openSettings(item.id)}
|
|
2056
|
+
onClick={() => openSettings(item.id as typeof tab)}
|
|
2057
|
+
disabled={item.disabled}
|
|
2020
2058
|
className={cn(
|
|
2021
|
-
'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-all duration-200',
|
|
2059
|
+
'shrink-0 rounded-full px-3 py-1.5 disabled:opacity-45 text-[11px] font-medium transition-all duration-200',
|
|
2022
2060
|
tab === item.id ? 'bg-ink text-panel' : 'bg-raised text-muted',
|
|
2023
2061
|
)}
|
|
2024
2062
|
>
|
|
@@ -244,12 +244,13 @@ export function Sidebar() {
|
|
|
244
244
|
<button
|
|
245
245
|
type="button"
|
|
246
246
|
disabled
|
|
247
|
-
|
|
248
|
-
|
|
247
|
+
aria-disabled="true"
|
|
248
|
+
title="Plugins are temporarily disabled for maintenance"
|
|
249
|
+
className="nav-item cursor-not-allowed opacity-50"
|
|
249
250
|
>
|
|
250
251
|
<Package size={15} strokeWidth={1.7} className="shrink-0 text-faint" />
|
|
251
252
|
Plugins
|
|
252
|
-
<span className="ml-auto rounded bg-
|
|
253
|
+
<span className="ml-auto rounded-full bg-warning/10 px-1.5 py-px text-[9px] font-medium text-warning">Maintenance</span>
|
|
253
254
|
</button>
|
|
254
255
|
<button
|
|
255
256
|
type="button"
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Building2, MessageSquare, ScrollText } from 'lucide-react'
|
|
4
|
+
import type { ChatSession } from '../types'
|
|
5
|
+
import { useSettings } from '../store/settings'
|
|
6
|
+
import { useChats } from '../store/chats'
|
|
7
|
+
import { useUi } from '../store/ui'
|
|
8
|
+
import { useWorkspace } from '../store/workspace'
|
|
9
|
+
import { inheritedPrompt, resolveBasePrompt } from '../lib/system-prompt'
|
|
10
|
+
import { useClickOutside } from '../lib/hooks'
|
|
11
|
+
import { cn } from '../lib/utils'
|
|
12
|
+
import { Segmented } from './ui'
|
|
13
|
+
|
|
14
|
+
type Scope = 'session' | 'workspace'
|
|
15
|
+
|
|
16
|
+
function Choice({
|
|
17
|
+
active,
|
|
18
|
+
title,
|
|
19
|
+
description,
|
|
20
|
+
onClick,
|
|
21
|
+
disabled,
|
|
22
|
+
}: {
|
|
23
|
+
active: boolean
|
|
24
|
+
title: string
|
|
25
|
+
description: string
|
|
26
|
+
onClick: () => void
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<button
|
|
31
|
+
type="button"
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
aria-pressed={active}
|
|
35
|
+
className={cn(
|
|
36
|
+
'flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
37
|
+
active ? 'border-accent/40 bg-accent-soft/60' : 'border-line hover:bg-raised/60',
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<span
|
|
41
|
+
className={cn(
|
|
42
|
+
'mt-[3px] flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border',
|
|
43
|
+
active ? 'border-accent' : 'border-faint',
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{active && <span className="h-1.5 w-1.5 rounded-full bg-accent" />}
|
|
47
|
+
</span>
|
|
48
|
+
<span className="min-w-0">
|
|
49
|
+
<span className="block text-[12px] font-medium text-ink">{title}</span>
|
|
50
|
+
<span className="mt-0.5 block text-[10.5px] leading-snug text-muted">{description}</span>
|
|
51
|
+
</span>
|
|
52
|
+
</button>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Small editor for the base system prompt: shared, custom for this session, or custom for this workspace. */
|
|
57
|
+
export function SystemPromptButton({ session }: { session: ChatSession | null }) {
|
|
58
|
+
const settings = useSettings()
|
|
59
|
+
const update = useSettings((state) => state.update)
|
|
60
|
+
const updateSession = useChats((state) => state.updateSession)
|
|
61
|
+
const notify = useUi((state) => state.notify)
|
|
62
|
+
const root = useWorkspace((state) => state.root || state.info.root)
|
|
63
|
+
const workspaceName = useWorkspace((state) => state.info.name)
|
|
64
|
+
const [open, setOpen] = useState(false)
|
|
65
|
+
const [scope, setScope] = useState<Scope>('session')
|
|
66
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
67
|
+
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
68
|
+
useClickOutside(panelRef, () => setOpen(false), open, triggerRef)
|
|
69
|
+
const [position, setPosition] = useState<{ top: number; right: number }>({ top: 56, right: 12 })
|
|
70
|
+
|
|
71
|
+
// The top bar blurs its backdrop, which traps children in its own stacking context.
|
|
72
|
+
// Render the panel in a portal and place it from the trigger's rectangle.
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
if (!open) return
|
|
75
|
+
const place = () => {
|
|
76
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
77
|
+
if (rect) setPosition({ top: rect.bottom + 8, right: Math.max(8, window.innerWidth - rect.right) })
|
|
78
|
+
}
|
|
79
|
+
place()
|
|
80
|
+
window.addEventListener('resize', place)
|
|
81
|
+
return () => window.removeEventListener('resize', place)
|
|
82
|
+
}, [open])
|
|
83
|
+
|
|
84
|
+
const effective = resolveBasePrompt(settings, session, root)
|
|
85
|
+
const sessionCustom = typeof session?.systemPrompt === 'string'
|
|
86
|
+
const workspaceCustom = Boolean(root) && typeof settings.workspacePrompts[root] === 'string'
|
|
87
|
+
const customized = sessionCustom || workspaceCustom
|
|
88
|
+
|
|
89
|
+
// Session needs a conversation, workspace needs a folder; fall back to whichever scope is usable.
|
|
90
|
+
const activeScope: Scope = scope === 'session' && !session && root ? 'workspace' : scope
|
|
91
|
+
const isSession = activeScope === 'session'
|
|
92
|
+
const custom = isSession ? sessionCustom : workspaceCustom
|
|
93
|
+
const value = isSession ? (session?.systemPrompt ?? '') : (settings.workspacePrompts[root] ?? '')
|
|
94
|
+
const fallback = isSession ? inheritedPrompt(settings, root) : { text: settings.systemPrompt, source: 'app' as const }
|
|
95
|
+
const unavailable = isSession ? !session : !root
|
|
96
|
+
|
|
97
|
+
const setCustom = (text: string | undefined) => {
|
|
98
|
+
if (isSession) {
|
|
99
|
+
if (session) updateSession(session.id, { systemPrompt: text })
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
if (!root) return
|
|
103
|
+
const next = { ...settings.workspacePrompts }
|
|
104
|
+
if (text === undefined) delete next[root]
|
|
105
|
+
else next[root] = text
|
|
106
|
+
update({ workspacePrompts: next })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sharedDescription = isSession
|
|
110
|
+
? fallback.source === 'workspace'
|
|
111
|
+
? 'Uses this workspace’s custom prompt.'
|
|
112
|
+
: 'Uses the app-wide prompt from Settings → General.'
|
|
113
|
+
: 'Uses the app-wide prompt from Settings → General.'
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className="relative hidden sm:block">
|
|
117
|
+
<button
|
|
118
|
+
ref={triggerRef}
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => setOpen((value) => !value)}
|
|
121
|
+
title={`System prompt · ${effective.source === 'session' ? 'custom for this session' : effective.source === 'workspace' ? 'custom for this workspace' : 'shared'}`}
|
|
122
|
+
aria-label="Edit system prompt"
|
|
123
|
+
aria-expanded={open}
|
|
124
|
+
className={cn('icon-button relative', open && 'bg-raised text-ink')}
|
|
125
|
+
>
|
|
126
|
+
<ScrollText size={16} strokeWidth={1.7} />
|
|
127
|
+
{customized && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-accent" />}
|
|
128
|
+
</button>
|
|
129
|
+
|
|
130
|
+
{open &&
|
|
131
|
+
createPortal(
|
|
132
|
+
<div
|
|
133
|
+
ref={panelRef}
|
|
134
|
+
role="dialog"
|
|
135
|
+
aria-label="System prompt"
|
|
136
|
+
style={{ top: position.top, right: position.right }}
|
|
137
|
+
className="fixed z-[95] max-h-[calc(100dvh-80px)] w-[min(420px,92vw)] overflow-y-auto rounded-2xl border border-line bg-panel p-3.5 shadow-float"
|
|
138
|
+
>
|
|
139
|
+
<div className="mb-2.5 flex items-center justify-between gap-3">
|
|
140
|
+
<p className="text-[12.5px] font-semibold text-ink">System prompt</p>
|
|
141
|
+
<Segmented<Scope>
|
|
142
|
+
value={activeScope}
|
|
143
|
+
onChange={setScope}
|
|
144
|
+
options={[
|
|
145
|
+
{ value: 'session', label: 'Session', title: 'Only this conversation' },
|
|
146
|
+
{ value: 'workspace', label: 'Workspace', title: 'Every conversation in this folder' },
|
|
147
|
+
]}
|
|
148
|
+
/>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<p className="mb-2 flex items-center gap-1.5 text-[10.5px] text-muted">
|
|
152
|
+
{isSession ? <MessageSquare size={11} /> : <Building2 size={11} />}
|
|
153
|
+
{isSession ? (session ? 'Applies to this conversation only.' : 'Send a message to start a conversation first.') : root ? `Applies to every conversation in ${workspaceName || 'this folder'}.` : 'Open a workspace folder first.'}
|
|
154
|
+
</p>
|
|
155
|
+
|
|
156
|
+
<div className="space-y-1.5">
|
|
157
|
+
<Choice
|
|
158
|
+
active={!custom}
|
|
159
|
+
disabled={unavailable}
|
|
160
|
+
title="Use shared prompt"
|
|
161
|
+
description={sharedDescription}
|
|
162
|
+
onClick={() => setCustom(undefined)}
|
|
163
|
+
/>
|
|
164
|
+
<Choice
|
|
165
|
+
active={custom}
|
|
166
|
+
disabled={unavailable}
|
|
167
|
+
title={isSession ? 'Custom for this session' : 'Custom for this workspace'}
|
|
168
|
+
description="Write a prompt that replaces the shared one here."
|
|
169
|
+
onClick={() => {
|
|
170
|
+
if (!custom) {
|
|
171
|
+
setCustom(fallback.text)
|
|
172
|
+
notify('Started from the current shared prompt. Edit it below.', 'info')
|
|
173
|
+
}
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{custom && !unavailable && (
|
|
179
|
+
<textarea
|
|
180
|
+
value={value}
|
|
181
|
+
onChange={(event) => setCustom(event.target.value)}
|
|
182
|
+
rows={9}
|
|
183
|
+
spellCheck={false}
|
|
184
|
+
aria-label="Custom system prompt"
|
|
185
|
+
className="thin-scroll mt-2.5 w-full resize-y rounded-xl border border-line bg-canvas px-3 py-2 font-mono text-[11px] leading-relaxed text-ink outline-none focus:border-accent/50"
|
|
186
|
+
placeholder="You are…"
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
<p className="mt-2 text-[10px] leading-relaxed text-faint">
|
|
191
|
+
Order of precedence: session, then workspace, then shared. Skills, workspace context and mode rules are always added after it.
|
|
192
|
+
</p>
|
|
193
|
+
</div>,
|
|
194
|
+
document.body,
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|