Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SyntaxSenpai Desktop

Primary SyntaxSenpai app: Electron main/preload, Vue 3 renderer, Pinia state, UnoCSS styling, keychain-backed provider keys, agent tools, mobile QR pairing, plugins, custom waifus, Live2D avatar support, and strict-mode shell gating.

## Run

From the repository root:

```bash
pnpm install
pnpm dev:desktop
```

Package-local commands:

```bash
pnpm --filter syntax-senpai-desktop run dev
pnpm --filter syntax-senpai-desktop run build
pnpm --filter syntax-senpai-desktop run start
pnpm --filter syntax-senpai-desktop run typecheck
pnpm --filter syntax-senpai-desktop run test:unit
pnpm --filter syntax-senpai-desktop run lint
```

## Important Paths

| Path | Purpose |
|---|---|
| `src/main/` | Electron main process, IPC, tray, shortcuts, crash logging |
| `src/preload/` | Safe bridge exposed to the renderer |
| `src/renderer/src/App.vue` | Main desktop UI |
| `src/renderer/src/stores/chat.ts` | Chat orchestration, provider calls, tools, prompt assembly |
| `src/renderer/src/agent-tools.ts` | Renderer-side tool definitions |
| `src/main/ipc/` | IPC handlers for tools, settings, storage, plugins, waifus, strict mode, WeChat, and runtime helpers |
| `scripts/verify-live2d-render.mjs` | Live2D smoke verification script |
| `src/main/agent/executor.ts` | Allowlist-based strict-mode executor and shared shell helpers |

## Provider Keys

Configure keys in **Settings -> AI**. Desktop stores keys through the OS keychain path; do not use committed `.env` files for normal app usage.

## Notes

- `pnpm dev:desktop` is the normal entry point from the repository root.
- The renderer owns the tool list; main-process IPC just executes the requested action safely.
9 changes: 7 additions & 2 deletions apps/desktop/src/renderer/src/components/ChatBubble.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ type RenderedPart =
const copied = ref(false)
const containerRef = ref<HTMLDivElement>()

function sanitizeForBubble(value: string): string {
return String(value || '').replace(/\[emotion:\s*[a-z]+\]/gi, '').replace(/\s{2,}/g, ' ').trim()
}

function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
Expand Down Expand Up @@ -164,7 +168,7 @@ function renderMarkdown(value: string): string {

async function handleCopy() {
try {
const text = props.content || containerRef.value?.innerText || ''
const text = displayContent.value || containerRef.value?.innerText || ''
if (!text) return
await navigator.clipboard.writeText(text)
copied.value = true
Expand All @@ -175,6 +179,7 @@ async function handleCopy() {
}

const hasCard = computed(() => renderedParts.value.some((p) => p.kind === 'card'))
const displayContent = computed(() => sanitizeForBubble(props.content ?? ''))

const bubbleClasses = computed(() => [
'relative px-4 py-3 rounded-xl',
Expand Down Expand Up @@ -217,7 +222,7 @@ function splitOnCardFences(raw: string): RenderedPart[] {
}

const renderedParts = computed<RenderedPart[]>(() => {
const raw = props.content || ''
const raw = displayContent.value || ''
if (!raw) return []
if (!raw.includes(CARD_FENCE)) {
return [{ kind: 'html', html: renderMarkdown(raw) }]
Expand Down
83 changes: 79 additions & 4 deletions apps/desktop/src/renderer/src/components/Live2DAvatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type Live2DModelType = any // pixi-live2d-display types vary by cubism version
const props = withDefaults(defineProps<{
/** Absolute file:// path (or URL) to the .model3.json / .model.json */
modelPath: string
/** Optional directory upload FileList generated from <input webkitdirectory>. */
folderFiles?: File[] | FileList | null
/** WaifuExpression name; component maps it to a motion group */
expression?: string
/** Bump to force reapplying an unchanged expression value. */
Expand Down Expand Up @@ -71,19 +73,23 @@ const DEFAULT_MOTION_MAP: Record<string, string> = {
embarrassed: 'TapBody',
determined: 'TapBody',
sad: 'FlickHead',
angry: 'FlickHead',
surprised: 'TapBody',
}

// Expression name → Cubism named-expression ID map.
// Many Live2D models ship predefined expressions like "Happy", "Sad", etc.
// We try these IDs first before falling back to raw parameter blending.
const EXPRESSION_NAME_MAP: Record<string, string[]> = {
happy: ['Happy', 'Smile', 'Joy', 'fun'],
excited: ['Excited', 'Surprised', 'Energetic', 'fun'],
thinking: ['Thinking', 'Ponder', 'Worry', 'angry'],
confused: ['Confused', 'Puzzle', 'Question', 'angry'],
excited: ['Excited', 'Energetic', 'fun'],
thinking: ['Thinking', 'Ponder', 'Worry'],
confused: ['Confused', 'Puzzle', 'Question'],
embarrassed: ['Embarrassed', 'Shy', 'Blush', 'sad'],
determined: ['Determined', 'Serious', 'Confident', 'angry', 'fun'],
sad: ['Sad', 'Cry', 'Sorrow', 'sad'],
angry: ['Angry', 'Mad', 'angry'],
surprised: ['Surprised', 'Surprise', 'Amazed', 'excited'],
neutral: ['Neutral', 'Default', 'Idle', 'base'],
}

Expand Down Expand Up @@ -159,6 +165,24 @@ const EXPRESSION_PARAMS: Record<string, ParamDef[]> = {
{ id: ['ParamBrowLAngle', 'PARAM_BROW_L_ANGLE'], value: 0.3 },
{ id: ['ParamBrowRAngle', 'PARAM_BROW_R_ANGLE'], value: -0.3 },
],
angry: [
{ id: ['ParamMouthForm', 'PARAM_MOUTH_FORM'], value: -0.2 },
{ id: ['ParamMouthOpenY', 'PARAM_MOUTH_OPEN_Y'], value: 0.2 },
{ id: ['ParamEyeLOpen', 'PARAM_EYE_L_OPEN'], value: 0.42 },
{ id: ['ParamEyeROpen', 'PARAM_EYE_R_OPEN'], value: 0.42 },
{ id: ['ParamBrowLY', 'PARAM_BROW_L_Y'], value: 0.24 },
{ id: ['ParamBrowRY', 'PARAM_BROW_R_Y'], value: 0.24 },
{ id: ['ParamBrowLAngle', 'PARAM_BROW_L_ANGLE'], value: -0.4 },
{ id: ['ParamBrowRAngle', 'PARAM_BROW_R_ANGLE'], value: -0.4 },
],
surprised: [
{ id: ['ParamMouthForm', 'PARAM_MOUTH_FORM'], value: 0.20 },
{ id: ['ParamMouthOpenY', 'PARAM_MOUTH_OPEN_Y'], value: 0.70 },
{ id: ['ParamEyeLOpen', 'PARAM_EYE_L_OPEN'], value: 1.00 },
{ id: ['ParamEyeROpen', 'PARAM_EYE_R_OPEN'], value: 1.00 },
{ id: ['ParamBrowLY', 'PARAM_BROW_L_Y'], value: -0.1 },
{ id: ['ParamBrowRY', 'PARAM_BROW_R_Y'], value: -0.1 },
],
neutral: [
{ id: ['ParamMouthForm', 'PARAM_MOUTH_FORM'], value: 0.0 },
{ id: ['ParamMouthOpenY', 'PARAM_MOUTH_OPEN_Y'], value: 0.0 },
Expand Down Expand Up @@ -247,6 +271,9 @@ async function initModel() {
const modelUrl = props.modelPath
const isCubism4 = modelUrl.includes('.model3.json')

const folderFiles = props.folderFiles
const hasDirectoryFileList = !!folderFiles && Array.from(folderFiles as FileList).length > 0

// Load the runtime that this model needs BEFORE importing the
// pixi-live2d-display submodule — the submodule's module-level
// initialization probes for the runtime as it loads.
Expand Down Expand Up @@ -288,7 +315,14 @@ async function initModel() {
autoDensity: true,
})

live2dModel = await Live2DModel.from(modelUrl, { autoInteract: false })
if (hasDirectoryFileList && typeof (live2dModule as any).FileLoader?.createSettings === 'function') {
const files = Array.from(folderFiles as File[] | FileList)
const settings = await (live2dModule as any).FileLoader.createSettings(files)
await (live2dModule as any).FileLoader.upload(files, settings)
live2dModel = await Live2DModel.from(settings, { autoInteract: false })
} else {
live2dModel = await Live2DModel.from(modelUrl, { autoInteract: false })
}

layoutModel()
pixiApp.ticker.add(applyCursorFocus)
Expand Down Expand Up @@ -327,6 +361,47 @@ function layoutModel() {
live2dModel.y = props.offsetY
}

async function setLive2DExpression(live2dModelTarget: Live2DModelType | null, emotion: string | null | undefined) {
if (!live2dModelTarget || !emotion) return
const normalized = String(emotion || '').trim().toLowerCase()
if (!normalized) return

const exprMgr = live2dModelTarget.internalModel?.motionManager?.expressionManager
const candidates = EXPRESSION_NAME_MAP[normalized]
let namedExpressionSet = false

if (exprMgr?.definitions?.length && candidates) {
for (const name of candidates) {
const index = exprMgr.getExpressionIndex(name)
if (index >= 0 && index < exprMgr.definitions.length) {
try {
await live2dModelTarget.expression(name)
namedExpressionSet = true
break
} catch {
/* try next expression id */
}
}
}
}

if (!namedExpressionSet) {
const params = EXPRESSION_PARAMS[normalized]
if (params) {
for (const p of params) {
setCoreParameter(p.id, p.value)
}
}
}

try {
const motionName = resolveMotion(normalized)
live2dModelTarget.motion(motionName)
} catch {
/* motion optional */
}
}

async function setExpression(expression: string) {
if (!live2dModel) return

Expand Down
34 changes: 33 additions & 1 deletion apps/desktop/src/renderer/src/stores/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,27 @@ function extractExplicitTerminalCommand(text: string): string | null {
return null
}

const EMOTION_TAG_RE = /\[emotion:\s*([a-z]+)\]/i
const EMOTION_LABELS = new Set(['happy', 'sad', 'angry', 'surprised', 'neutral', 'excited', 'thinking', 'confused', 'embarrassed', 'determined'])

function buildEmotionPromptBlock(): string {
return `

[Emotion Tags]
When you answer in chat, you MUST emit a single final emotion tag somewhere in your visible reply body using the exact format [emotion: <value>], where <value> is one of: happy, sad, angry, surprised, neutral, excited, thinking, confused, embarrassed, determined.
Keep the tag as runtime metadata for the avatar; do not explain it. Strip it from the live rendered text before the user sees it.`
}

function parseEmotionTag(content: string): { emotion: string | null; content: string } {
const raw = String(content || '')
const match = raw.match(EMOTION_TAG_RE)
if (!match?.[1]) return { emotion: null, content: raw }
const emotion = match[1].toLowerCase()
if (!EMOTION_LABELS.has(emotion)) return { emotion: null, content: raw }
const stripped = raw.replace(EMOTION_TAG_RE, '').replace(/\s{2,}/g, ' ').trim()
return { emotion, content: stripped }
}

function createWaifuSystemPrompt(waifu: any, provider: string, model: string, affection: number) {
return buildSystemPrompt(
waifu,
Expand Down Expand Up @@ -1841,6 +1862,7 @@ export const useChatStore = defineStore('chat', () => {
let cachedSystemPrompt = createWaifuSystemPrompt(waifu, selectedProvider.value, model, affection.value)
cachedSystemPrompt += buildMasterContextBlock()
cachedSystemPrompt += buildLanguagePromptBlock()
cachedSystemPrompt += buildEmotionPromptBlock()
cachedSystemPrompt += buildSkillsAuthoringPromptBlock()
cachedSystemPrompt += formatSkillsForPrompt(availableSkills.value)
cachedSystemPrompt += buildWeChatSessionPromptBlock(currentWeChatBinding.value)
Expand All @@ -1852,6 +1874,7 @@ export const useChatStore = defineStore('chat', () => {
systemPrompt += buildAffectionPrompt(affection.value, waifu.displayName || 'Waifu')
systemPrompt += buildMilestoneSidecarBlock(waifu.id)
systemPrompt += buildApiTelemetryPrompt()
systemPrompt += buildEmotionPromptBlock()
systemPrompt += buildCurrentTimePrompt()
systemPrompt += activeCodingRepo.value
? buildActiveCodingRepoPromptBlock(activeCodingRepo.value)
Expand Down Expand Up @@ -2041,6 +2064,11 @@ export const useChatStore = defineStore('chat', () => {
}
let finalText = extractMemoryFromAIResponse(finalRaw)
if (pendingCards.length > 0) finalText = prependCardMarkers(pendingCards, finalText)
const parsed = parseEmotionTag(finalText)
if (parsed.emotion) {
applyLive2DExpression(parsed.emotion, 'agent')
finalText = parsed.content
}
ensureBubble()
assistantContent = finalText
updateBubble()
Expand Down Expand Up @@ -2074,7 +2102,11 @@ export const useChatStore = defineStore('chat', () => {
assistantContent = extractMemoryFromAIResponse(assistantContent)
}

const cleanContent = assistantContent
const parsed = parseEmotionTag(assistantContent)
if (parsed.emotion) {
applyLive2DExpression(parsed.emotion, 'agent')
}
const cleanContent = parsed.content
const savedMessage = messages.value.find((m) => m.id === assistantId)
if (savedMessage) {
savedMessage.content = cleanContent
Expand Down
86 changes: 50 additions & 36 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,38 +1,52 @@
{
"name": "syntax-senpai",
"version": "0.0.1",
"packageManager": "pnpm@8.13.0",
"description": "AI-powered waifu companion for developers",
"private": true,
"license": "MIT",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"desktop:quick": "pnpm --filter syntax-senpai-desktop run dev:quick",
"dev:mobile": "turbo run dev --filter=syntax-senpai-mobile",
"dev:desktop": "turbo run dev --filter=syntax-senpai-desktop",
"dev:runtime": "pnpm --filter syntax-senpai-runtime run dev",
"build": "turbo run build",
"test": "turbo run test",
"test:unit": "pnpm -r --if-present run test:unit",
"lint": "turbo run lint",
"typecheck": "tsc -p tsconfig.base.json --noEmit",
"docker:build": "docker build -f Dockerfile -t syntax-senpai-runtime:local .",
"docker:up": "docker compose up --build",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"@types/node": "20.3.1",
"prebuild-install": "^7.1.3",
"tsdown": "0.21.7",
"turbo": "^2.9.14",
"typescript": "^5.7.2",
"vitest": "^4.1.3"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=11.0.0"
"name": "syntax-senpai",
"version": "0.0.1",
"packageManager": "pnpm@8.13.0",
"description": "AI-powered waifu companion for developers",
"private": true,
"license": "MIT",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"desktop:quick": "pnpm --filter syntax-senpai-desktop run dev:quick",
"dev:mobile": "turbo run dev --filter=syntax-senpai-mobile",
"dev:desktop": "turbo run dev --filter=syntax-senpai-desktop",
"dev:runtime": "pnpm --filter syntax-senpai-runtime run dev",
"build": "turbo run build",
"test": "turbo run test",
"test:unit": "pnpm -r --if-present run test:unit",
"lint": "turbo run lint",
"typecheck": "tsc -p tsconfig.base.json --noEmit",
"docker:build": "docker build -f Dockerfile -t syntax-senpai-runtime:local .",
"docker:up": "docker compose up --build",
"clean": "turbo run clean && rm -rf node_modules"
},
"devDependencies": {
"@types/node": "20.3.1",
"prebuild-install": "^7.1.3",
"tsdown": "0.21.7",
"turbo": "^2.9.14",
"typescript": "^5.7.2",
"vitest": "^4.1.3"
},
"pnpm": {
"overrides": {
"@xmldom/xmldom": "0.8.13",
"brace-expansion": "5.0.6",
"gh-pages": ">=5.0.0",
"postcss": "8.5.15",
"tar": "7.5.15",
"uuid": "11.1.1",
"ws": "8.21.0",
"shell-quote": "1.8.4",
"tmp": "0.2.7",
"esbuild": "0.28.1"
}
}
},
"engines": {
"node": ">=20.0.0",
"npm": ">=11.0.0"
}
}
Loading
Loading